judge_rails 0.0.1
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 +7 -0
- data/ADVANCED.md +136 -0
- data/BENCHMARK.md +885 -0
- data/CHANGELOG.md +163 -0
- data/LICENSE.txt +21 -0
- data/README.md +588 -0
- data/lib/generators/judge/attribute_generator.rb +149 -0
- data/lib/generators/judge/install_generator.rb +36 -0
- data/lib/generators/judge/templates/initializer.rb.tt +18 -0
- data/lib/generators/judge/templates/migration.rb.tt +9 -0
- data/lib/judge/adapter.rb +69 -0
- data/lib/judge/client.rb +253 -0
- data/lib/judge/configuration.rb +103 -0
- data/lib/judge/decision.rb +24 -0
- data/lib/judge/errors.rb +36 -0
- data/lib/judge/facade.rb +75 -0
- data/lib/judge/pool.rb +97 -0
- data/lib/judge/question/choice.rb +36 -0
- data/lib/judge/question/noul.rb +22 -0
- data/lib/judge/question/score.rb +36 -0
- data/lib/judge/question.rb +93 -0
- data/lib/judge/rails/attributes.rb +161 -0
- data/lib/judge/rails/definition.rb +156 -0
- data/lib/judge/rails/jobs.rb +156 -0
- data/lib/judge/rails/locale/en.yml +6 -0
- data/lib/judge/rails/migration.rb +115 -0
- data/lib/judge/rails/refresh.rb +37 -0
- data/lib/judge/rails/refresh_job.rb +15 -0
- data/lib/judge/rails/registry.rb +57 -0
- data/lib/judge/rails/relation.rb +132 -0
- data/lib/judge/rails/scopes.rb +124 -0
- data/lib/judge/rails/storage.rb +104 -0
- data/lib/judge/rails/validator.rb +246 -0
- data/lib/judge/rails.rb +45 -0
- data/lib/judge/result.rb +173 -0
- data/lib/judge/result_set.rb +75 -0
- data/lib/judge/version.rb +5 -0
- data/lib/judge.rb +41 -0
- data/lib/judge_rails.rb +3 -0
- metadata +126 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/named_base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Judge
|
|
7
|
+
module Generators
|
|
8
|
+
class AttributeGenerator < ::Rails::Generators::NamedBase
|
|
9
|
+
include ::ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Adds Judge judgment attributes to a model: a migration plus the model declarations."
|
|
14
|
+
|
|
15
|
+
argument :judge_attributes, type: :array, default: [], banner: "name:noul name:choice name:score"
|
|
16
|
+
class_option :database, type: :string, aliases: %i[--db],
|
|
17
|
+
desc: "The database whose migrations path receives the migration"
|
|
18
|
+
|
|
19
|
+
TYPES = %w[noul choice score].freeze
|
|
20
|
+
NAME = /\A[a-z_][a-z0-9_]*\z/
|
|
21
|
+
|
|
22
|
+
QUESTION_ARGUMENTS = {
|
|
23
|
+
"noul" => "",
|
|
24
|
+
"choice" => ", %w[first_option second_option third_option]",
|
|
25
|
+
"score" => ", 1..5"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def validate_pairs
|
|
29
|
+
raise ::Rails::Generators::Error, "give at least one name:type pair" if pairs.empty?
|
|
30
|
+
|
|
31
|
+
invalid = pairs.map(&:first).grep_v(NAME)
|
|
32
|
+
unless invalid.empty?
|
|
33
|
+
raise ::Rails::Generators::Error,
|
|
34
|
+
"invalid attribute name(s) #{invalid.join(", ")}: " \
|
|
35
|
+
"use lowercase letters, digits and underscores"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
unknown = pairs.reject { |_, type| TYPES.include?(type) }
|
|
39
|
+
return if unknown.empty?
|
|
40
|
+
|
|
41
|
+
raise ::Rails::Generators::Error,
|
|
42
|
+
"unknown judge type(s) #{unknown.map(&:last).uniq.join(", ")}, expected #{TYPES.join(", ")}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def create_migration_file
|
|
46
|
+
migration_template "migration.rb.tt", File.join(db_migrate_path, "#{migration_basename}.rb")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def inject_model_declarations
|
|
50
|
+
return say_status(:skip, "#{model_path} not found", :yellow) unless model_source
|
|
51
|
+
return remove_declarations if behavior == :revoke
|
|
52
|
+
|
|
53
|
+
declarations = missing_declarations
|
|
54
|
+
return say_status(:identical, model_path, :blue) if declarations.empty?
|
|
55
|
+
|
|
56
|
+
if judge_source?
|
|
57
|
+
point = insertion_point
|
|
58
|
+
unless point
|
|
59
|
+
return say_status(:skip, "could not find class #{class_name} in #{model_path}; add:\n" \
|
|
60
|
+
"#{declarations.join}", :yellow)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
inject_into_file model_path, declarations.join, after: /\A#{Regexp.escape(model_source[0...point])}/
|
|
64
|
+
else
|
|
65
|
+
inject_into_class model_path, class_name, "#{source_line}#{declarations.join}\n"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def pairs
|
|
72
|
+
@pairs ||= judge_attributes.map do |pair|
|
|
73
|
+
name, type = pair.split(":", 2)
|
|
74
|
+
[name, type.to_s]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def migration_basename
|
|
79
|
+
"add_judge_#{pairs.map(&:first).join("_")}_to_#{table_name}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def model_path
|
|
83
|
+
File.join("app", "models", class_path, "#{file_name}.rb")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def model_source
|
|
87
|
+
return @model_source if defined?(@model_source)
|
|
88
|
+
|
|
89
|
+
full = File.join(destination_root, model_path)
|
|
90
|
+
@model_source = File.exist?(full) ? File.read(full) : nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def judge_source?
|
|
94
|
+
model_source.to_s.match?(/^\s*judge_source\b/)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def insertion_point
|
|
98
|
+
lines = model_source.lines
|
|
99
|
+
class_index = lines.index do |line|
|
|
100
|
+
line.match?(/^\s*class\s+(?:[\w:]+::)?#{Regexp.escape(class_name.demodulize)}\b/)
|
|
101
|
+
end
|
|
102
|
+
return unless class_index
|
|
103
|
+
|
|
104
|
+
indent = lines[class_index][/\A\s*/]
|
|
105
|
+
closing = ((class_index + 1)...lines.size).find { |i| lines[i].match?(/\A#{indent}end\b/) }
|
|
106
|
+
closing && lines[0...closing].join.length
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def source_line
|
|
110
|
+
columns = text_columns
|
|
111
|
+
return " judge_source { [#{columns.join(", ")}] }\n" if columns.any?
|
|
112
|
+
|
|
113
|
+
" judge_source { [] } # TODO: the attributes Judge should read; nothing is judged until you set it\n"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def text_columns
|
|
117
|
+
model = class_name.safe_constantize
|
|
118
|
+
return [] unless model.respond_to?(:columns)
|
|
119
|
+
|
|
120
|
+
model.columns.select { |c| c.type == :text }.map(&:name).grep_v(/_judge\z/)
|
|
121
|
+
rescue StandardError
|
|
122
|
+
[]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def missing_declarations
|
|
126
|
+
pairs.reject { |name, _| declared?(name) }.map { |name, type| " #{declaration(name, type)}\n" }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def remove_declarations
|
|
130
|
+
pairs.each do |name, type|
|
|
131
|
+
gsub_file model_path, " #{declaration(name, type)}\n", "", force: true
|
|
132
|
+
end
|
|
133
|
+
remaining = File.read(File.join(destination_root, model_path))
|
|
134
|
+
return if remaining.match?(/^\s*judge_attribute\b/)
|
|
135
|
+
|
|
136
|
+
gsub_file model_path, source_line, "", force: true
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def declared?(name)
|
|
140
|
+
model_source.to_s.match?(/^\s*judge_attribute\s+:#{Regexp.escape(name)}\b/)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def declaration(name, type)
|
|
144
|
+
prompt = %("TODO: describe the judgment for #{name}"#{QUESTION_ARGUMENTS.fetch(type)})
|
|
145
|
+
"judge_attribute :#{name}, Judge.#{type}(#{prompt})"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Judge
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
desc "Creates config/initializers/judge.rb and explains how to supply an API key."
|
|
11
|
+
|
|
12
|
+
def create_initializer
|
|
13
|
+
template "initializer.rb.tt", "config/initializers/judge.rb"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def print_next_steps
|
|
17
|
+
return unless behavior == :invoke
|
|
18
|
+
|
|
19
|
+
say ""
|
|
20
|
+
say "Judge is installed.", :green
|
|
21
|
+
say ""
|
|
22
|
+
say " 1. Provide an API key, either as the JEV_API_KEY environment variable"
|
|
23
|
+
say " or as the Rails credential judge.api_key (bin/rails credentials:edit)."
|
|
24
|
+
say " 2. Add judgments to a model, for example:"
|
|
25
|
+
say ""
|
|
26
|
+
say " bin/rails generate judge:attribute Ticket urgency:noul intent:choice"
|
|
27
|
+
say ""
|
|
28
|
+
say " 3. Replace the TODO questions and check judge_source: until you do, every save"
|
|
29
|
+
say " is judged with the placeholder wording, and each judgment is a billed call."
|
|
30
|
+
say " 4. Run bin/rails db:migrate. Async attributes (the default) need ActiveJob;"
|
|
31
|
+
say " backfill existing rows with Ticket.judge_refresh_all."
|
|
32
|
+
say ""
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Judge.configure do |config|
|
|
4
|
+
# Never commit a key. Set JEV_API_KEY (or TYPESAFE_API_KEY) in the environment, or store it
|
|
5
|
+
# in Rails credentials under judge.api_key (bin/rails credentials:edit).
|
|
6
|
+
config.api_key ||= Rails.application.credentials.dig(:judge, :api_key)
|
|
7
|
+
|
|
8
|
+
config.logger = Rails.logger
|
|
9
|
+
|
|
10
|
+
# Defaults, uncomment to override:
|
|
11
|
+
#
|
|
12
|
+
# config.base_url = "https://api.typesafe.ai/v1/systemone"
|
|
13
|
+
# config.model = "jev-latest"
|
|
14
|
+
# config.timeout = 10.0
|
|
15
|
+
# config.open_timeout = 5.0
|
|
16
|
+
# config.max_retries = 2
|
|
17
|
+
# config.max_retry_wait = 10.0
|
|
18
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
<% pairs.each do |name, type| -%>
|
|
6
|
+
judge_attribute :<%= table_name %>, :<%= name %>, :<%= type %>
|
|
7
|
+
<% end -%>
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
# An adapter answers `call(state:, questions:, model:) -> Judge::ResultSet`, building each
|
|
5
|
+
# result with `Judge::Result.from_values`. Nothing outside an adapter knows a wire format.
|
|
6
|
+
module Adapter
|
|
7
|
+
@lock = Mutex.new
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def registry
|
|
11
|
+
@registry ||= { jev: -> { Client.new } }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def register(name, &build)
|
|
15
|
+
raise ArgumentError, "register needs a block returning an adapter" unless build
|
|
16
|
+
|
|
17
|
+
@lock.synchronize do
|
|
18
|
+
registry[name.to_sym] = build
|
|
19
|
+
@built = nil
|
|
20
|
+
end
|
|
21
|
+
name.to_sym
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build(name)
|
|
25
|
+
return name if name.respond_to?(:call)
|
|
26
|
+
|
|
27
|
+
factory = registry[name.to_s.to_sym]
|
|
28
|
+
raise ConfigurationError, unknown(name) unless factory
|
|
29
|
+
|
|
30
|
+
factory.call
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def resolve(name)
|
|
34
|
+
return name if name.respond_to?(:call)
|
|
35
|
+
|
|
36
|
+
built = @built
|
|
37
|
+
return built.last if built&.first == name
|
|
38
|
+
|
|
39
|
+
adapter = build(name)
|
|
40
|
+
@lock.synchronize do
|
|
41
|
+
@built = [name, adapter] unless @built&.first == name
|
|
42
|
+
@built.last
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def names
|
|
47
|
+
registry.keys
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def reset!
|
|
51
|
+
@lock.synchronize do
|
|
52
|
+
@registry = nil
|
|
53
|
+
@built = nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reset_built!
|
|
58
|
+
@lock.synchronize { @built = nil }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def unknown(name)
|
|
64
|
+
"unknown adapter #{name.inspect}. Known: #{names.map(&:inspect).join(", ")}. " \
|
|
65
|
+
"Register one with Judge::Adapter.register(#{name.to_s.to_sym.inspect}) { ... }"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/judge/client.rb
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "zlib"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Judge
|
|
10
|
+
class Client
|
|
11
|
+
BACKOFF_BASE = 0.25
|
|
12
|
+
BACKOFF_CAP = 8.0
|
|
13
|
+
CONNECTIONS_KEY = :judge_client_connections
|
|
14
|
+
TRANSPORT_ERRORS = [Timeout::Error, SocketError, IOError, SystemCallError, OpenSSL::SSL::SSLError,
|
|
15
|
+
Net::ProtocolError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Zlib::Error].freeze
|
|
16
|
+
UNSAFE_TO_RESEND = [Net::ReadTimeout].freeze
|
|
17
|
+
RETRYABLE_STATUSES = (500..599)
|
|
18
|
+
|
|
19
|
+
attr_reader :config
|
|
20
|
+
attr_accessor :sleeper
|
|
21
|
+
|
|
22
|
+
def self.close_thread_connections
|
|
23
|
+
store = Thread.current[CONNECTIONS_KEY]
|
|
24
|
+
Thread.current[CONNECTIONS_KEY] = nil
|
|
25
|
+
return unless store && store[:pid] == Process.pid
|
|
26
|
+
|
|
27
|
+
store[:connections].each_value do |http|
|
|
28
|
+
http.finish if http.started?
|
|
29
|
+
rescue IOError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def inspect
|
|
35
|
+
"#<Judge::Client base_url=#{@config.base_url.inspect} model=#{@config.model.inspect}>"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(config: nil, sleeper: nil)
|
|
39
|
+
@config = config || Judge.config
|
|
40
|
+
@sleeper = sleeper || Kernel.method(:sleep)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(state:, questions:, model: nil)
|
|
44
|
+
raise ArgumentError, "state must not be nil" if state.nil?
|
|
45
|
+
raise ArgumentError, "questions must not be empty" if questions.nil? || questions.empty?
|
|
46
|
+
|
|
47
|
+
payload = JSON.generate(build_payload(state, questions, model))
|
|
48
|
+
event = { model: model || @config.model, questions: questions.size,
|
|
49
|
+
request_bytes: payload.bytesize }
|
|
50
|
+
|
|
51
|
+
instrument(event) do
|
|
52
|
+
started = monotonic
|
|
53
|
+
body = perform(payload)
|
|
54
|
+
set = ResultSet.from_response(body, questions: questions, latency: monotonic - started)
|
|
55
|
+
event[:latency] = set.latency
|
|
56
|
+
event[:input_tokens] = set.usage&.input_tokens
|
|
57
|
+
event[:output_tokens] = set.usage&.output_tokens
|
|
58
|
+
set
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def instrument(event, &)
|
|
65
|
+
return yield unless defined?(::ActiveSupport::Notifications)
|
|
66
|
+
|
|
67
|
+
::ActiveSupport::Notifications.instrument("request.judge", event, &)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_payload(state, questions, model)
|
|
71
|
+
{
|
|
72
|
+
"state" => state,
|
|
73
|
+
"model" => model || @config.model,
|
|
74
|
+
"questions" => questions.to_h { |name, question| [name.to_s, question.to_payload] }
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def perform(payload)
|
|
79
|
+
authorization = "Bearer #{@config.api_key!}"
|
|
80
|
+
attempt = 0
|
|
81
|
+
|
|
82
|
+
loop do
|
|
83
|
+
attempt += 1
|
|
84
|
+
response = attempt_request(authorization, payload, attempt)
|
|
85
|
+
next if response.nil?
|
|
86
|
+
|
|
87
|
+
status = response.code.to_i
|
|
88
|
+
return parse_body(response) if status < 300
|
|
89
|
+
|
|
90
|
+
raise error_for(status, response) unless retryable?(status) && attempt <= @config.max_retries
|
|
91
|
+
|
|
92
|
+
pause(retry_delay(response, status, attempt))
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def retry_delay(response, status, attempt)
|
|
97
|
+
after = [429, 503].include?(status) ? retry_after(response) : nil
|
|
98
|
+
raise error_for(status, response) if after && @config.max_retry_wait && after > @config.max_retry_wait
|
|
99
|
+
|
|
100
|
+
after || backoff(attempt)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def attempt_request(authorization, payload, attempt)
|
|
104
|
+
started = monotonic
|
|
105
|
+
response = execute(authorization, payload)
|
|
106
|
+
log(response.code, monotonic - started, attempt)
|
|
107
|
+
response
|
|
108
|
+
rescue *TRANSPORT_ERRORS => e
|
|
109
|
+
close_connection
|
|
110
|
+
log(e.class.name, monotonic - started, attempt)
|
|
111
|
+
if attempt > @config.max_retries || UNSAFE_TO_RESEND.any? { |klass| e.is_a?(klass) }
|
|
112
|
+
raise TransportError, "#{e.class}: #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
pause(backoff(attempt))
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def execute(authorization, payload)
|
|
120
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
121
|
+
request["Authorization"] = authorization
|
|
122
|
+
request["Content-Type"] = "application/json"
|
|
123
|
+
request["Accept"] = "application/json"
|
|
124
|
+
request.body = payload
|
|
125
|
+
connection.request(request)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def uri
|
|
129
|
+
url = @config.base_url
|
|
130
|
+
@uri = URI.parse(url) if @uri_source != url
|
|
131
|
+
@uri_source = url
|
|
132
|
+
@uri
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def connection_key
|
|
136
|
+
[@config.base_url, @config.open_timeout, @config.timeout].freeze
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def connection
|
|
140
|
+
store = connections
|
|
141
|
+
key = connection_key
|
|
142
|
+
http = store[key]
|
|
143
|
+
return http if http&.started?
|
|
144
|
+
|
|
145
|
+
store[key] = start_connection(key.first)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def start_connection(base_url)
|
|
149
|
+
uri = URI.parse(base_url)
|
|
150
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
151
|
+
http.use_ssl = uri.scheme == "https"
|
|
152
|
+
http.open_timeout = @config.open_timeout
|
|
153
|
+
http.read_timeout = @config.timeout
|
|
154
|
+
http.write_timeout = @config.timeout if http.respond_to?(:write_timeout=)
|
|
155
|
+
http.keep_alive_timeout = 30
|
|
156
|
+
http.start
|
|
157
|
+
http
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def connections
|
|
161
|
+
store = Thread.current[CONNECTIONS_KEY]
|
|
162
|
+
unless store && store[:pid] == Process.pid
|
|
163
|
+
store = { pid: Process.pid, connections: {} }
|
|
164
|
+
Thread.current[CONNECTIONS_KEY] = store
|
|
165
|
+
end
|
|
166
|
+
store[:connections]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def close_connection
|
|
170
|
+
http = connections.delete(connection_key)
|
|
171
|
+
http.finish if http&.started?
|
|
172
|
+
rescue IOError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def parse_body(response)
|
|
177
|
+
body = response.body.to_s
|
|
178
|
+
parsed = JSON.parse(body)
|
|
179
|
+
raise InvalidResponseError, "expected a JSON object, got #{parsed.class}" unless parsed.is_a?(Hash)
|
|
180
|
+
|
|
181
|
+
parsed
|
|
182
|
+
rescue JSON::ParserError => e
|
|
183
|
+
raise InvalidResponseError, "could not parse response body: #{e.message}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def retryable?(status)
|
|
187
|
+
status == 429 || RETRYABLE_STATUSES.cover?(status)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def retry_after(response)
|
|
191
|
+
value = response["Retry-After"]
|
|
192
|
+
return nil if value.nil?
|
|
193
|
+
|
|
194
|
+
Float(value)
|
|
195
|
+
rescue ArgumentError, TypeError
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def backoff(attempt)
|
|
200
|
+
window = [BACKOFF_BASE * (2**(attempt - 1)), BACKOFF_CAP].min
|
|
201
|
+
window * (0.5 + (rand * 0.5))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def pause(seconds)
|
|
205
|
+
@sleeper.call(seconds) if seconds.positive?
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def error_for(status, response)
|
|
209
|
+
body = response.body.to_s
|
|
210
|
+
message = "Judge API returned #{status}: #{error_message(body)}"
|
|
211
|
+
|
|
212
|
+
case status
|
|
213
|
+
when 401, 403 then AuthenticationError.new(message, status: status, body: body)
|
|
214
|
+
when 400, 404, 422 then InvalidRequestError.new(message, status: status, body: body)
|
|
215
|
+
when 413 then PayloadTooLargeError.new(message, status: status, body: body)
|
|
216
|
+
when 429
|
|
217
|
+
RateLimitError.new(message, status: status, body: body, retry_after: retry_after(response))
|
|
218
|
+
when RETRYABLE_STATUSES then ServerError.new(message, status: status, body: body)
|
|
219
|
+
else APIError.new(message, status: status, body: body)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def error_message(body)
|
|
224
|
+
parsed = JSON.parse(body)
|
|
225
|
+
return truncate(body) unless parsed.is_a?(Hash)
|
|
226
|
+
|
|
227
|
+
error = parsed["error"]
|
|
228
|
+
message = error.is_a?(Hash) ? error["message"] : error
|
|
229
|
+
(message || parsed["message"] || truncate(body)).to_s
|
|
230
|
+
rescue JSON::ParserError
|
|
231
|
+
truncate(body)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def truncate(body, limit = 200)
|
|
235
|
+
body.length > limit ? "#{body[0, limit]}..." : body
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def log(status, latency, attempt)
|
|
239
|
+
logger = @config.logger
|
|
240
|
+
return unless logger
|
|
241
|
+
|
|
242
|
+
logger.debug do
|
|
243
|
+
"Judge POST #{uri.path} status=#{status} latency=#{latency.round(3)}s attempt=#{attempt}"
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def monotonic
|
|
248
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
Judge::Pool.on_worker_exit { Judge::Client.close_thread_connections }
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
class Configuration
|
|
5
|
+
DEFAULT_BASE_URL = "https://api.typesafe.ai/v1/systemone"
|
|
6
|
+
DEFAULT_MODEL = "jev-latest"
|
|
7
|
+
|
|
8
|
+
attr_reader :api_key, :base_url, :model, :timeout, :open_timeout, :max_retries, :max_retry_wait,
|
|
9
|
+
:concurrency, :adapter
|
|
10
|
+
attr_accessor :logger
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
self.api_key = [ENV.fetch("JEV_API_KEY", nil), ENV.fetch("TYPESAFE_API_KEY", nil)]
|
|
14
|
+
.find { |value| !blank?(value) }
|
|
15
|
+
self.base_url = present_env("JEV_BASE_URL") || DEFAULT_BASE_URL
|
|
16
|
+
self.model = present_env("JEV_MODEL") || DEFAULT_MODEL
|
|
17
|
+
self.timeout = 10.0
|
|
18
|
+
self.open_timeout = 5.0
|
|
19
|
+
self.max_retries = 2
|
|
20
|
+
self.max_retry_wait = 10.0
|
|
21
|
+
self.concurrency = nil
|
|
22
|
+
self.adapter = present_env("JUDGE_ADAPTER") || :jev
|
|
23
|
+
@logger = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def api_key=(value)
|
|
27
|
+
@api_key = blank?(value) ? nil : value.to_s.strip
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def base_url=(value)
|
|
31
|
+
raise ArgumentError, "base_url must not be blank" if blank?(value)
|
|
32
|
+
|
|
33
|
+
@base_url = value.to_s.strip
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def model=(value)
|
|
37
|
+
raise ArgumentError, "model must not be blank" if blank?(value)
|
|
38
|
+
|
|
39
|
+
@model = value.to_s.strip
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def timeout=(value)
|
|
43
|
+
@timeout = positive_number(value, "timeout")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def open_timeout=(value)
|
|
47
|
+
@open_timeout = positive_number(value, "open_timeout")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def max_retries=(value)
|
|
51
|
+
unless value.is_a?(Integer) && !value.negative?
|
|
52
|
+
raise ArgumentError, "max_retries must be an Integer of 0 or more, got #{value.inspect}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@max_retries = value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def max_retry_wait=(value)
|
|
59
|
+
@max_retry_wait = value.nil? ? nil : positive_number(value, "max_retry_wait")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def concurrency=(value)
|
|
63
|
+
unless value.nil? || (value.is_a?(Integer) && value.positive?)
|
|
64
|
+
raise ArgumentError, "concurrency must be nil or a positive Integer, got #{value.inspect}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@concurrency = value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def adapter=(value)
|
|
71
|
+
@adapter = value.respond_to?(:call) ? value : value.to_s.strip.to_sym
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def api_key!
|
|
75
|
+
return @api_key if @api_key
|
|
76
|
+
|
|
77
|
+
raise ConfigurationError, "No Judge API key. Set JEV_API_KEY or Judge.configure { |c| c.api_key = ... }"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def inspect
|
|
81
|
+
key = @api_key ? "[FILTERED]" : "nil"
|
|
82
|
+
"#<Judge::Configuration api_key=#{key} base_url=#{@base_url.inspect} model=#{@model.inspect} " \
|
|
83
|
+
"adapter=#{@adapter.inspect}>"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def present_env(name)
|
|
89
|
+
value = ENV.fetch(name, nil)
|
|
90
|
+
blank?(value) ? nil : value.strip
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def blank?(value)
|
|
94
|
+
value.nil? || value.to_s.strip.empty?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def positive_number(value, label)
|
|
98
|
+
return value.to_f if value.is_a?(Numeric) && value.positive?
|
|
99
|
+
|
|
100
|
+
raise ArgumentError, "#{label} must be a positive number of seconds, got #{value.inspect}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
module Decision
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def call(probability, above:, below: nil)
|
|
8
|
+
low = band_floor(above, below)
|
|
9
|
+
return :yes if probability >= above
|
|
10
|
+
return :no if probability <= low
|
|
11
|
+
|
|
12
|
+
:unsure
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def band_floor(above, below)
|
|
16
|
+
low = below || [(1.0 - above).round(12), above].min
|
|
17
|
+
return low if low <= above
|
|
18
|
+
|
|
19
|
+
raise ArgumentError,
|
|
20
|
+
"decide needs below (#{low}) to be at or under above (#{above}), " \
|
|
21
|
+
"otherwise no probability can land in :no or :unsure"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/judge/errors.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ConfigurationError < Error; end
|
|
7
|
+
|
|
8
|
+
class TransportError < Error; end
|
|
9
|
+
|
|
10
|
+
class APIError < Error
|
|
11
|
+
attr_reader :status, :body
|
|
12
|
+
|
|
13
|
+
def initialize(message, status: nil, body: nil)
|
|
14
|
+
@status = status
|
|
15
|
+
@body = body
|
|
16
|
+
super(message)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class AuthenticationError < APIError; end
|
|
21
|
+
class InvalidRequestError < APIError; end
|
|
22
|
+
class ServerError < APIError; end
|
|
23
|
+
|
|
24
|
+
class RateLimitError < APIError
|
|
25
|
+
attr_reader :retry_after
|
|
26
|
+
|
|
27
|
+
def initialize(message, status: nil, body: nil, retry_after: nil)
|
|
28
|
+
@retry_after = retry_after
|
|
29
|
+
super(message, status: status, body: body)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class PayloadTooLargeError < APIError; end
|
|
34
|
+
|
|
35
|
+
class InvalidResponseError < Error; end
|
|
36
|
+
end
|