typesafe-ai-rails 0.4.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: d274cc3f15ebcb2e9660a74947c1e7487d930aef743b286a3735a046e010d45a
4
+ data.tar.gz: 555561362e7bfe28c3db08b8edd74d7fca31dee5831de921142a8b3dfe456f15
5
+ SHA512:
6
+ metadata.gz: 3e656b5c1000a3dcf37d29303b2c70489a1e6d98205a6d9eb35c84199b43bcb2a1a1f84e0b1c29e26481cd7b98f7893c89fe815600e6693b239feba0af75a221
7
+ data.tar.gz: 8a97bb57ff81e1ccc0fdc398fe13ad4ce41d29a8de38cc7b85d796ef2f46a8038c10f33e5b14688c41f0aec784b32abddbf7b7082236c0ba6ec140c88a595190
data/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ /.bundle/
2
+ /Gemfile.lock
3
+ /pkg/
4
+ *.gem
5
+ .DS_Store
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0
4
+
5
+ Initial public release under the `typesafe-ai-rails` name.
6
+
7
+ - Fail-closed Choice/Score confidence gating backed by Rails persistence.
8
+ - Answer-specific policies with wildcard defaults and distinct fallback handlers.
9
+ - Explicit Noul handling: probability is never treated as confidence.
10
+ - Forward-compatible plain-hash question helpers with documented Score validation.
11
+ - Direct per-call SDK keyword forwarding.
12
+ - Model-aware, non-fatal call/cost telemetry with pricing snapshots, latency, usage,
13
+ and request IDs.
14
+ - Jev-family pricing handles versioned response model names such as `jev-1.13.0`.
15
+ - SDK logging is opt-in to avoid accidental request/response body logging.
16
+ - Tests use the real community `typesafe-sdk` request/response contract.
17
+ - Fresh-install and development-snapshot upgrade migrations.
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Genie Developments
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # typesafe-ai-rails
2
+
3
+ Community Rails integration for TypeSafe AI's System One API, built on the
4
+ community [`typesafe-sdk`](https://github.com/joshmn/typesafe-sdk) Ruby gem.
5
+ This project is not an official TypeSafe package.
6
+
7
+ The SDK stays framework-neutral. `typesafe-ai-rails` adds Rails configuration,
8
+ persisted usage/cost telemetry, and an opt-in persistence-backed confidence
9
+ policy for Choice and Score answers.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ bundle add typesafe-ai-rails
15
+ bin/rails generate typesafe:rails:install
16
+ bin/rails db:migrate
17
+ ```
18
+
19
+ Bundler loads the gem through `typesafe-ai-rails`. The stable Ruby API remains
20
+ under `Typesafe::Rails`; direct users may also `require "typesafe/rails"`.
21
+
22
+ Add the API key to Rails credentials:
23
+
24
+ ```yaml
25
+ typesafe:
26
+ api_key: sk-...
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```ruby
32
+ result = Typesafe::Rails.client.ask(
33
+ decision_type: "support_ticket_routing",
34
+ state: ticket.body,
35
+ questions: {
36
+ department: Typesafe::Rails.choice(
37
+ "Which team should handle this",
38
+ { billing: nil, technical: nil, sales: nil }
39
+ ),
40
+ is_urgent: Typesafe::Rails.noul("Is this time-sensitive?"),
41
+ frustration: Typesafe::Rails.score(
42
+ "How frustrated is the customer",
43
+ ["Calm", "Frustrated but civil", "Very angry"]
44
+ )
45
+ }
46
+ )
47
+ ```
48
+
49
+ `ask` sends all questions about the state in one System One call. That follows
50
+ TypeSafe's recommendation to batch independent questions that share state.
51
+
52
+ The returned `Result` exposes the SDK response directly:
53
+
54
+ ```ruby
55
+ result[:department]
56
+ result.choices
57
+ result.scores
58
+ result.nouls
59
+ result.usage
60
+ result.response
61
+ ```
62
+
63
+ ## Confidence policies
64
+
65
+ TypeSafe returns `confidence` for Choice and Score answers. Noul does **not**
66
+ have a separate confidence value; its `noul` field is the probability of yes.
67
+
68
+ `Result#act!` is a fail-closed helper for Choice/Score side effects. Create
69
+ either a policy for a specific answer or a wildcard policy for the decision:
70
+
71
+ ```ruby
72
+ Typesafe::Rails::DecisionPolicy.create!(
73
+ decision_type: "support_ticket_routing",
74
+ answer_key: "department",
75
+ confidence_threshold: 0.7,
76
+ fallback: "surface_to_user"
77
+ )
78
+ ```
79
+
80
+ A wildcard row applies to every confidence-bearing answer that does not have a
81
+ more specific row:
82
+
83
+ ```ruby
84
+ Typesafe::Rails::DecisionPolicy.create!(
85
+ decision_type: "support_ticket_routing",
86
+ answer_key: "*",
87
+ confidence_threshold: 0.5,
88
+ fallback: "surface_to_user"
89
+ )
90
+ ```
91
+
92
+ Then gate the side effect:
93
+
94
+ ```ruby
95
+ result.act!(
96
+ :department,
97
+ fallback: ->(_answer) { route_to_human(ticket) }
98
+ ) do |answer|
99
+ ticket.route_to!(answer.choice)
100
+ end
101
+ ```
102
+
103
+ If no active policy exists, `act!` raises
104
+ `Typesafe::Rails::MissingPolicyError`. Intentionally ungated reads should use
105
+ `result[:department]` directly.
106
+
107
+ For distinct fallback modes, pass handlers by mode:
108
+
109
+ ```ruby
110
+ result.act!(
111
+ :department,
112
+ fallback: {
113
+ deterministic_rule: ->(answer) { route_with_rules(ticket, answer) },
114
+ surface_to_user: ->(_answer) { ask_customer(ticket) },
115
+ missing_answer: ->(_answer) { route_to_human(ticket) }
116
+ }
117
+ ) do |answer|
118
+ ticket.route_to!(answer.choice)
119
+ end
120
+ ```
121
+
122
+ `fallback: "escalate"` raises `Typesafe::Rails::LowConfidenceError`.
123
+
124
+ For Noul, threshold its probability directly:
125
+
126
+ ```ruby
127
+ urgent = result[:is_urgent]
128
+ escalate(ticket) if urgent.noul >= 0.8
129
+ ```
130
+
131
+ ## Question helpers
132
+
133
+ `Typesafe::Rails.noul`, `.choice`, and `.score` return plain question hashes.
134
+ The Ruby SDK accepts hashes, so new API fields can pass through before the SDK
135
+ adds matching constructor keywords:
136
+
137
+ ```ruby
138
+ Typesafe::Rails.noul(
139
+ "Is this relevant?",
140
+ weight: 2,
141
+ future_field: { enabled: true }
142
+ )
143
+ ```
144
+
145
+ Choice requires a non-empty criteria hash. Score requires the currently
146
+ documented 2–10 ordered levels.
147
+
148
+ You can always use `Typesafe::SDK::Noul`, `Choice`, `Score`, or raw hashes
149
+ directly in the same `questions:` map.
150
+
151
+ ## Configuration
152
+
153
+ ```ruby
154
+ Rails.application.config.typesafe.api_key =
155
+ Rails.application.credentials.dig(:typesafe, :api_key)
156
+
157
+ Rails.application.config.typesafe.model = "jev-latest"
158
+ Rails.application.config.typesafe.timeout = 10.0
159
+ ```
160
+
161
+ Client-level SDK options are available through Rails configuration:
162
+ `base_url`, `headers`, `user_agent`, `logger`, `retry_policy`, and `transport`.
163
+
164
+ SDK logging is deliberately opt-in. At debug level, the Ruby SDK logs request
165
+ and response bodies, so enabling it may place application state in logs:
166
+
167
+ ```ruby
168
+ Rails.application.config.typesafe.logger = Rails.logger
169
+ ```
170
+
171
+ Per-call SDK options are forwarded directly:
172
+
173
+ ```ruby
174
+ Typesafe::Rails.client.ask(
175
+ decision_type: "routing",
176
+ state: ticket.body,
177
+ questions: questions,
178
+ model: "jev-1.12",
179
+ extra_body: { beam_width: 4 }
180
+ )
181
+ ```
182
+
183
+ For anything else, `Typesafe::Rails.client.sdk` exposes the underlying client.
184
+ Calls made directly on it bypass Rails telemetry.
185
+
186
+ ## Telemetry and pricing
187
+
188
+ Each successful `ask` attempts to append a row to `typesafe_calls` containing:
189
+
190
+ - decision type and returned model
191
+ - input/output token counts
192
+ - the pricing rates used for the estimate
193
+ - estimated USD cost
194
+ - request ID and local latency
195
+
196
+ Pricing is keyed by the returned model. The built-in defaults include a Jev
197
+ family rate so `jev-latest` can resolve to versioned names such as `jev-1.13.0`
198
+ without losing the cost estimate. Unknown model families are recorded with
199
+ `cost_usd = NULL` rather than an invented price. Override `pricing` when
200
+ TypeSafe changes its published rates.
201
+
202
+ A telemetry database failure is non-fatal by default. Set
203
+ `Rails.application.config.typesafe.strict_logging = true` when complete
204
+ accounting is more important than availability.
205
+
206
+ ## License
207
+
208
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test" << "lib"
8
+ t.pattern = "test/**/*_test.rb"
9
+ t.verbose = true
10
+ end
11
+
12
+ task default: :test
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Typesafe
7
+ module Rails
8
+ module Generators
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ include ::Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ desc "Installs or upgrades TypeSafe AI Rails integration tables and configuration."
15
+
16
+ def self.next_migration_number(dirname)
17
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
18
+ end
19
+
20
+ def create_migration_files
21
+ if migration_exists?("create_typesafe_rails_tables")
22
+ unless migration_exists?("upgrade_typesafe_rails_to_0_4")
23
+ migration_template(
24
+ "upgrade_typesafe_rails_to_0_4.rb.tt",
25
+ "db/migrate/upgrade_typesafe_rails_to_0_4.rb"
26
+ )
27
+ end
28
+ else
29
+ migration_template(
30
+ "create_typesafe_rails_tables.rb.tt",
31
+ "db/migrate/create_typesafe_rails_tables.rb"
32
+ )
33
+ end
34
+ end
35
+
36
+ def add_initializer
37
+ template "typesafe.rb.tt", "config/initializers/typesafe.rb"
38
+ end
39
+
40
+ def show_readme
41
+ readme "POST_INSTALL.md" if behavior == :invoke
42
+ end
43
+
44
+ private
45
+
46
+ def migration_exists?(basename)
47
+ Dir.glob(File.join(destination_root, "db/migrate/*_#{basename}.rb")).any?
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,20 @@
1
+ Typesafe::Rails is installed.
2
+
3
+ Next steps:
4
+
5
+ 1. Run `bin/rails db:migrate`.
6
+
7
+ 2. Add your API key: `bin/rails credentials:edit` and add:
8
+
9
+ typesafe:
10
+ api_key: sk-...
11
+
12
+ 3. Call System One through `Typesafe::Rails.client.ask`.
13
+
14
+ 4. Before calling `result.act!`, create an active policy for that answer
15
+ key, or a wildcard `answer_key: "*"` policy for the decision type.
16
+ `act!` intentionally fails closed when no policy exists.
17
+
18
+ Noul answers do not have TypeSafe confidence. Read `answer.noul` and apply
19
+ the probability threshold appropriate to your application instead of using
20
+ `act!`.
@@ -0,0 +1,30 @@
1
+ class CreateTypesafeRailsTables < ActiveRecord::Migration<%= "[#{ActiveRecord::Migration.current_version}]" %>
2
+ def change
3
+ create_table :typesafe_decision_policies do |t|
4
+ t.string :decision_type, null: false
5
+ t.string :answer_key, null: false, default: "*"
6
+ t.decimal :confidence_threshold, precision: 3, scale: 2, null: false
7
+ t.string :fallback, null: false, default: "surface_to_user"
8
+ t.boolean :active, null: false, default: true
9
+
10
+ t.timestamps
11
+ end
12
+ add_index :typesafe_decision_policies, [:decision_type, :answer_key], unique: true
13
+
14
+ create_table :typesafe_calls do |t|
15
+ t.string :decision_type, null: false
16
+ t.string :model
17
+ t.integer :input_tokens
18
+ t.integer :output_tokens
19
+ t.decimal :input_rate, precision: 12, scale: 6
20
+ t.decimal :output_rate, precision: 12, scale: 6
21
+ t.decimal :cost_usd, precision: 12, scale: 6
22
+ t.decimal :latency_ms, precision: 12, scale: 3
23
+ t.string :request_id
24
+ t.datetime :created_at, null: false
25
+ end
26
+ add_index :typesafe_calls, :decision_type
27
+ add_index :typesafe_calls, :created_at
28
+ add_index :typesafe_calls, :request_id
29
+ end
30
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.config.typesafe.api_key =
4
+ Rails.application.credentials.dig(:typesafe, :api_key)
5
+
6
+ Rails.application.config.typesafe.model = "jev-latest"
7
+ Rails.application.config.typesafe.timeout = 10.0
8
+
9
+ # SDK logging is opt-in. Debug logging includes request/response bodies, which
10
+ # may contain sensitive application state.
11
+ # Rails.application.config.typesafe.logger = Rails.logger
12
+
13
+ # A telemetry write failure should not make a successful TypeSafe call fail.
14
+ # Set this true when complete accounting is more important than availability.
15
+ Rails.application.config.typesafe.strict_logging = false
16
+
17
+ # TypeSafe currently prices the Jev family uniformly. Unknown model families
18
+ # are logged with cost_usd = NULL rather than an invented price.
19
+ Rails.application.config.typesafe.pricing = {
20
+ "jev" => { input_per_mtok: 0.042, output_per_mtok: 0.0 },
21
+ "jev-latest" => { input_per_mtok: 0.042, output_per_mtok: 0.0 },
22
+ "jev-1.12" => { input_per_mtok: 0.042, output_per_mtok: 0.0 }
23
+ }
@@ -0,0 +1,30 @@
1
+ class UpgradeTypesafeRailsTo04 < ActiveRecord::Migration<%= "[#{ActiveRecord::Migration.current_version}]" %>
2
+ def up
3
+ unless column_exists?(:typesafe_decision_policies, :answer_key)
4
+ add_column :typesafe_decision_policies, :answer_key, :string, null: false, default: "*"
5
+ end
6
+
7
+ if index_exists?(:typesafe_decision_policies, :decision_type, unique: true)
8
+ remove_index :typesafe_decision_policies, :decision_type
9
+ end
10
+
11
+ unless index_exists?(:typesafe_decision_policies, [:decision_type, :answer_key], unique: true)
12
+ add_index :typesafe_decision_policies, [:decision_type, :answer_key], unique: true
13
+ end
14
+
15
+ add_column :typesafe_calls, :model, :string unless column_exists?(:typesafe_calls, :model)
16
+ add_column :typesafe_calls, :input_rate, :decimal,
17
+ precision: 12, scale: 6 unless column_exists?(:typesafe_calls, :input_rate)
18
+ add_column :typesafe_calls, :output_rate, :decimal,
19
+ precision: 12, scale: 6 unless column_exists?(:typesafe_calls, :output_rate)
20
+ add_column :typesafe_calls, :latency_ms, :decimal,
21
+ precision: 12, scale: 3 unless column_exists?(:typesafe_calls, :latency_ms)
22
+
23
+ add_index :typesafe_calls, :request_id unless index_exists?(:typesafe_calls, :request_id)
24
+ end
25
+
26
+ def down
27
+ raise ActiveRecord::IrreversibleMigration,
28
+ "0.4 allows multiple answer-specific policies per decision_type"
29
+ end
30
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module Rails
5
+ class CallLog < ActiveRecord::Base
6
+ self.table_name = "typesafe_calls"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "typesafe/sdk"
4
+
5
+ module Typesafe
6
+ module Rails
7
+ class Client
8
+ SDK_OPTIONS = %i[base_url headers user_agent logger retry_policy transport].freeze
9
+
10
+ DEFAULT_PRICING = {
11
+ "jev" => { input_per_mtok: 0.042, output_per_mtok: 0.0 },
12
+ "jev-latest" => { input_per_mtok: 0.042, output_per_mtok: 0.0 },
13
+ "jev-1.12" => { input_per_mtok: 0.042, output_per_mtok: 0.0 }
14
+ }.freeze
15
+
16
+ def self.from_config
17
+ config = Typesafe::Rails.configuration
18
+
19
+ new(
20
+ api_key: config&.api_key || ::Rails.application.credentials.dig(:typesafe, :api_key),
21
+ model: config&.model || "jev-latest",
22
+ timeout: config&.timeout || 10.0,
23
+ pricing: config&.pricing || DEFAULT_PRICING,
24
+ strict_logging: config&.strict_logging || false,
25
+ **sdk_options_from(config)
26
+ )
27
+ end
28
+
29
+ def self.sdk_options_from(config)
30
+ SDK_OPTIONS.each_with_object({}) do |key, acc|
31
+ value = config&.public_send(key)
32
+ acc[key] = value unless value.nil?
33
+ end
34
+ end
35
+ private_class_method :sdk_options_from
36
+
37
+ def initialize(
38
+ api_key:,
39
+ model: "jev-latest",
40
+ timeout: 10.0,
41
+ pricing: DEFAULT_PRICING,
42
+ strict_logging: false,
43
+ sdk_client: nil,
44
+ **sdk_options
45
+ )
46
+ @pricing = pricing
47
+ @strict_logging = strict_logging
48
+ @sdk = sdk_client || Typesafe::SDK::Client.new(
49
+ api_key: api_key, model: model, timeout: timeout, **sdk_options
50
+ )
51
+ end
52
+
53
+ def ask(decision_type:, state:, questions:, **call_options)
54
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
55
+ response = @sdk.system_one(state: state, questions: questions, **call_options)
56
+ latency_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
57
+
58
+ record_call(response, decision_type: decision_type, latency_ms: latency_ms)
59
+ Result.new(response: response, decision_type: decision_type)
60
+ end
61
+
62
+ def models
63
+ @sdk.models
64
+ end
65
+
66
+ def close
67
+ @sdk.close if @sdk.respond_to?(:close)
68
+ nil
69
+ end
70
+
71
+ attr_reader :sdk
72
+
73
+ private
74
+
75
+ def record_call(response, decision_type:, latency_ms:)
76
+ rates = pricing_for(response.model)
77
+ input_rate = rate_value(rates, :input_per_mtok)
78
+ output_rate = rate_value(rates, :output_per_mtok)
79
+
80
+ CallLog.create!(
81
+ decision_type: decision_type.to_s,
82
+ model: response.model,
83
+ input_tokens: response.usage&.input_tokens,
84
+ output_tokens: response.usage&.output_tokens,
85
+ input_rate: input_rate,
86
+ output_rate: output_rate,
87
+ cost_usd: estimate_cost(response.usage, input_rate, output_rate),
88
+ latency_ms: latency_ms,
89
+ request_id: response.respond_to?(:request_id) ? response.request_id : nil
90
+ )
91
+ rescue StandardError => error
92
+ raise if @strict_logging
93
+
94
+ warn_logging_failure(error)
95
+ end
96
+
97
+ def pricing_for(model)
98
+ return @pricing.call(model) if @pricing.respond_to?(:call)
99
+ return nil unless @pricing.respond_to?(:[])
100
+ return @pricing if rate_hash?(@pricing)
101
+
102
+ exact = @pricing[model] ||
103
+ (model.respond_to?(:to_sym) ? @pricing[model.to_sym] : nil)
104
+ return exact unless exact.nil?
105
+
106
+ if model.to_s.start_with?("jev-")
107
+ family = @pricing["jev"] || @pricing[:jev]
108
+ return family unless family.nil?
109
+ end
110
+
111
+ @pricing["default"] || @pricing[:default]
112
+ end
113
+
114
+ def rate_hash?(value)
115
+ value.is_a?(Hash) &&
116
+ (value.key?(:input_per_mtok) || value.key?("input_per_mtok"))
117
+ end
118
+
119
+ def rate_value(rates, key)
120
+ return nil unless rates.respond_to?(:[])
121
+
122
+ value = rates[key]
123
+ value = rates[key.to_s] if value.nil?
124
+ value&.to_f
125
+ end
126
+
127
+ def estimate_cost(usage, input_rate, output_rate)
128
+ return nil if usage.nil? || input_rate.nil? || output_rate.nil?
129
+ return nil if usage.input_tokens.nil?
130
+ return nil if output_rate.nonzero? && usage.output_tokens.nil?
131
+
132
+ input_cost = usage.input_tokens.to_f / 1_000_000 * input_rate
133
+ output_tokens = usage.output_tokens || 0
134
+ output_cost = output_tokens.to_f / 1_000_000 * output_rate
135
+ input_cost + output_cost
136
+ end
137
+
138
+ def warn_logging_failure(error)
139
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
140
+
141
+ ::Rails.logger.warn(
142
+ "typesafe-ai-rails could not persist call telemetry: #{error.class}: #{error.message}"
143
+ )
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module Rails
5
+ class DecisionPolicy < ActiveRecord::Base
6
+ self.table_name = "typesafe_decision_policies"
7
+
8
+ DEFAULT_ANSWER_KEY = "*"
9
+ FALLBACKS = %w[deterministic_rule surface_to_user escalate].freeze
10
+
11
+ validates :decision_type, presence: true
12
+ validates :answer_key, presence: true,
13
+ uniqueness: { scope: :decision_type }
14
+ validates :confidence_threshold,
15
+ numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }
16
+ validates :fallback, inclusion: { in: FALLBACKS }
17
+
18
+ scope :active, -> { where(active: true) }
19
+
20
+ def self.resolve(decision_type:, answer_key:)
21
+ decision_type = decision_type.to_s
22
+ answer_key = answer_key.to_s
23
+
24
+ active.find_by(decision_type: decision_type, answer_key: answer_key) ||
25
+ active.find_by(decision_type: decision_type, answer_key: DEFAULT_ANSWER_KEY)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module Rails
5
+ module Questions
6
+ def noul(instructions = nil, criteria = nil, **fields)
7
+ question_hash("noul", instructions, criteria, fields) do |value|
8
+ next if value.nil? || value.is_a?(Hash)
9
+
10
+ raise ArgumentError, "noul criteria must be a hash with true/false descriptions"
11
+ end
12
+ end
13
+
14
+ def choice(instructions = nil, criteria = nil, **fields)
15
+ question_hash("choice", instructions, criteria, fields) do |value|
16
+ unless value.is_a?(Hash) && !value.empty?
17
+ raise ArgumentError, "choice requires a non-empty criteria hash"
18
+ end
19
+ end
20
+ end
21
+
22
+ def score(instructions = nil, criteria = nil, **fields)
23
+ question_hash("score", instructions, criteria, fields) do |value|
24
+ unless value.is_a?(Array) && value.length.between?(2, 10)
25
+ raise ArgumentError, "score criteria must be an array with 2 to 10 levels"
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def question_hash(type, instructions, criteria, fields)
33
+ fields = fields.dup
34
+ reject_type_override!(fields, type)
35
+ instructions = merge_argument!(fields, :instructions, instructions)
36
+ criteria = merge_argument!(fields, :criteria, criteria)
37
+ yield(criteria) if block_given?
38
+
39
+ question = { "type" => type }
40
+ question["instructions"] = instructions unless instructions.nil?
41
+ question["criteria"] = criteria unless criteria.nil?
42
+ fields.each { |key, value| question[key.to_s] = value }
43
+ question
44
+ end
45
+
46
+ def merge_argument!(fields, name, positional)
47
+ return positional unless fields.key?(name)
48
+ raise ArgumentError, "#{name} given both positionally and as a keyword" unless positional.nil?
49
+
50
+ fields.delete(name)
51
+ end
52
+
53
+ def reject_type_override!(fields, expected)
54
+ return unless fields.key?(:type)
55
+
56
+ supplied = fields.delete(:type)
57
+ return if supplied.to_s == expected
58
+
59
+ raise ArgumentError, "#{expected} helper cannot build type #{supplied.inspect}"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module Rails
5
+ class Railtie < ::Rails::Railtie
6
+ config.typesafe = ActiveSupport::OrderedOptions.new
7
+
8
+ config.typesafe.api_key = nil
9
+ config.typesafe.base_url = nil
10
+ config.typesafe.model = "jev-latest"
11
+ config.typesafe.timeout = 10.0
12
+ config.typesafe.headers = nil
13
+ config.typesafe.user_agent = nil
14
+ config.typesafe.logger = nil
15
+ config.typesafe.retry_policy = nil
16
+ config.typesafe.transport = nil
17
+ config.typesafe.strict_logging = false
18
+ config.typesafe.pricing = Client::DEFAULT_PRICING.transform_values(&:dup)
19
+
20
+ initializer "typesafe.configure" do |app|
21
+ Typesafe::Rails.configuration = app.config.typesafe
22
+ end
23
+
24
+ generators do
25
+ require "generators/typesafe/rails/install/install_generator"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module Rails
5
+ class LowConfidenceError < StandardError
6
+ attr_reader :answer_key, :confidence, :policy
7
+
8
+ def initialize(answer_key, confidence, policy)
9
+ @answer_key = answer_key
10
+ @confidence = confidence
11
+ @policy = policy
12
+ super(
13
+ "#{answer_key.inspect} confidence #{confidence.inspect} is below the " \
14
+ "#{policy.confidence_threshold} threshold configured for " \
15
+ "decision_type #{policy.decision_type.inspect}"
16
+ )
17
+ end
18
+ end
19
+
20
+ class MissingPolicyError < StandardError
21
+ attr_reader :decision_type, :answer_key
22
+
23
+ def initialize(decision_type, answer_key)
24
+ @decision_type = decision_type
25
+ @answer_key = answer_key
26
+ super(
27
+ "no active TypeSafe decision policy for #{decision_type.inspect} / " \
28
+ "#{answer_key.inspect}; use Result#[] directly for intentionally ungated reads"
29
+ )
30
+ end
31
+ end
32
+
33
+ class UnsupportedConfidenceGateError < StandardError
34
+ attr_reader :answer_key, :answer
35
+
36
+ def initialize(answer_key, answer)
37
+ @answer_key = answer_key
38
+ @answer = answer
39
+ super(
40
+ "#{answer_key.inspect} returned #{answer.class}, which has no TypeSafe confidence value; " \
41
+ "Noul answers expose probability as #noul and must be thresholded explicitly"
42
+ )
43
+ end
44
+ end
45
+
46
+ class MissingConfidenceError < StandardError
47
+ attr_reader :answer_key, :answer
48
+
49
+ def initialize(answer_key, answer)
50
+ @answer_key = answer_key
51
+ @answer = answer
52
+ super("#{answer_key.inspect} returned a confidence-bearing answer with confidence=nil")
53
+ end
54
+ end
55
+
56
+ class Result
57
+ attr_reader :response, :decision_type
58
+
59
+ def initialize(response:, decision_type:)
60
+ @response = response
61
+ @decision_type = decision_type.to_s
62
+ end
63
+
64
+ def answers = response.answers
65
+ def nouls = response.nouls
66
+ def choices = response.choices
67
+ def scores = response.scores
68
+ def usage = response.usage
69
+ def [](key) = response[key]
70
+
71
+ def act!(answer_key, fallback:)
72
+ answer = response[answer_key]
73
+ return call_fallback(fallback, :missing_answer, nil) if answer.nil?
74
+
75
+ unless answer.respond_to?(:confidence)
76
+ raise UnsupportedConfidenceGateError.new(answer_key, answer)
77
+ end
78
+
79
+ confidence = answer.confidence
80
+ raise MissingConfidenceError.new(answer_key, answer) if confidence.nil?
81
+
82
+ policy = DecisionPolicy.resolve(decision_type: decision_type, answer_key: answer_key)
83
+ raise MissingPolicyError.new(decision_type, answer_key) if policy.nil?
84
+
85
+ return yield(answer) if confidence >= policy.confidence_threshold
86
+
87
+ case policy.fallback
88
+ when "escalate"
89
+ raise LowConfidenceError.new(answer_key, confidence, policy)
90
+ else
91
+ call_fallback(fallback, policy.fallback, answer)
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def call_fallback(fallback, mode, answer)
98
+ handler =
99
+ if fallback.respond_to?(:call)
100
+ fallback
101
+ elsif fallback.respond_to?(:[])
102
+ fallback[mode.to_sym] || fallback[mode.to_s]
103
+ end
104
+
105
+ unless handler.respond_to?(:call)
106
+ raise ArgumentError, "fallback handler for #{mode.inspect} is required"
107
+ end
108
+
109
+ handler.call(answer)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module Rails
5
+ VERSION = "0.4.0"
6
+ end
7
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "typesafe/rails/version"
5
+ require "typesafe/rails/questions"
6
+ require "typesafe/rails/decision_policy"
7
+ require "typesafe/rails/call_log"
8
+ require "typesafe/rails/result"
9
+ require "typesafe/rails/client"
10
+ require "typesafe/rails/railtie" if defined?(::Rails::Railtie)
11
+
12
+ module Typesafe
13
+ module Rails
14
+ class << self
15
+ include Questions
16
+
17
+ attr_accessor :configuration
18
+
19
+ def client
20
+ @client ||= Client.from_config
21
+ end
22
+
23
+ def reset_client!
24
+ @client&.close
25
+ @client = nil
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "typesafe/rails"
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/typesafe/rails/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "typesafe-ai-rails"
7
+ spec.version = Typesafe::Rails::VERSION
8
+ spec.authors = ["Genie Developments"]
9
+
10
+ spec.summary = "Community Rails integration for TypeSafe AI System One."
11
+ spec.description = "Rails configuration, model-aware call/cost telemetry, and fail-closed " \
12
+ "confidence policies for TypeSafe Choice and Score judgments on top of " \
13
+ "the community typesafe-sdk Ruby gem. This is not an official TypeSafe package."
14
+ spec.homepage = "https://github.com/GenieRobot/typesafe-ai-rails"
15
+ spec.license = "MIT"
16
+ spec.required_ruby_version = ">= 3.1"
17
+
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
21
+ spec.metadata["rubygems_mfa_required"] = "true"
22
+
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|\.github)/}) }
25
+ end
26
+ spec.require_paths = ["lib"]
27
+
28
+ spec.add_dependency "activerecord", ">= 7.0", "< 9.0"
29
+ spec.add_dependency "railties", ">= 7.0", "< 9.0"
30
+ spec.add_dependency "typesafe-sdk", "~> 0.3"
31
+
32
+ spec.add_development_dependency "minitest", "~> 5.0"
33
+ spec.add_development_dependency "rake", "~> 13.0"
34
+ spec.add_development_dependency "sqlite3", ">= 2.0", "< 3.0"
35
+ end
metadata ADDED
@@ -0,0 +1,171 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: typesafe-ai-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - Genie Developments
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: railties
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '9.0'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '7.0'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '9.0'
53
+ - !ruby/object:Gem::Dependency
54
+ name: typesafe-sdk
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '0.3'
60
+ type: :runtime
61
+ prerelease: false
62
+ version_requirements: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '0.3'
67
+ - !ruby/object:Gem::Dependency
68
+ name: minitest
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '5.0'
74
+ type: :development
75
+ prerelease: false
76
+ version_requirements: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '5.0'
81
+ - !ruby/object:Gem::Dependency
82
+ name: rake
83
+ requirement: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '13.0'
88
+ type: :development
89
+ prerelease: false
90
+ version_requirements: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '13.0'
95
+ - !ruby/object:Gem::Dependency
96
+ name: sqlite3
97
+ requirement: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '2.0'
102
+ - - "<"
103
+ - !ruby/object:Gem::Version
104
+ version: '3.0'
105
+ type: :development
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '2.0'
112
+ - - "<"
113
+ - !ruby/object:Gem::Version
114
+ version: '3.0'
115
+ description: Rails configuration, model-aware call/cost telemetry, and fail-closed
116
+ confidence policies for TypeSafe Choice and Score judgments on top of the community
117
+ typesafe-sdk Ruby gem. This is not an official TypeSafe package.
118
+ email:
119
+ executables: []
120
+ extensions: []
121
+ extra_rdoc_files: []
122
+ files:
123
+ - ".gitignore"
124
+ - CHANGELOG.md
125
+ - Gemfile
126
+ - LICENSE.txt
127
+ - README.md
128
+ - Rakefile
129
+ - lib/generators/typesafe/rails/install/install_generator.rb
130
+ - lib/generators/typesafe/rails/install/templates/POST_INSTALL.md
131
+ - lib/generators/typesafe/rails/install/templates/create_typesafe_rails_tables.rb.tt
132
+ - lib/generators/typesafe/rails/install/templates/typesafe.rb.tt
133
+ - lib/generators/typesafe/rails/install/templates/upgrade_typesafe_rails_to_0_4.rb.tt
134
+ - lib/typesafe-ai-rails.rb
135
+ - lib/typesafe/rails.rb
136
+ - lib/typesafe/rails/call_log.rb
137
+ - lib/typesafe/rails/client.rb
138
+ - lib/typesafe/rails/decision_policy.rb
139
+ - lib/typesafe/rails/questions.rb
140
+ - lib/typesafe/rails/railtie.rb
141
+ - lib/typesafe/rails/result.rb
142
+ - lib/typesafe/rails/version.rb
143
+ - typesafe-ai-rails.gemspec
144
+ homepage: https://github.com/GenieRobot/typesafe-ai-rails
145
+ licenses:
146
+ - MIT
147
+ metadata:
148
+ source_code_uri: https://github.com/GenieRobot/typesafe-ai-rails
149
+ changelog_uri: https://github.com/GenieRobot/typesafe-ai-rails/blob/main/CHANGELOG.md
150
+ bug_tracker_uri: https://github.com/GenieRobot/typesafe-ai-rails/issues
151
+ rubygems_mfa_required: 'true'
152
+ post_install_message:
153
+ rdoc_options: []
154
+ require_paths:
155
+ - lib
156
+ required_ruby_version: !ruby/object:Gem::Requirement
157
+ requirements:
158
+ - - ">="
159
+ - !ruby/object:Gem::Version
160
+ version: '3.1'
161
+ required_rubygems_version: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: '0'
166
+ requirements: []
167
+ rubygems_version: 3.5.22
168
+ signing_key:
169
+ specification_version: 4
170
+ summary: Community Rails integration for TypeSafe AI System One.
171
+ test_files: []