typesafe-ai-ruby 0.1.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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +176 -0
  5. data/lib/typesafe/client.rb +56 -0
  6. data/lib/typesafe/configuration.rb +129 -0
  7. data/lib/typesafe/constants.rb +49 -0
  8. data/lib/typesafe/errors.rb +117 -0
  9. data/lib/typesafe/http/connection_manager.rb +82 -0
  10. data/lib/typesafe/http/net_http_transport.rb +74 -0
  11. data/lib/typesafe/http/request.rb +30 -0
  12. data/lib/typesafe/http/requestor.rb +153 -0
  13. data/lib/typesafe/http/response.rb +48 -0
  14. data/lib/typesafe/http/retrier.rb +57 -0
  15. data/lib/typesafe/instrumentation.rb +84 -0
  16. data/lib/typesafe/logging.rb +37 -0
  17. data/lib/typesafe/questions/choice.rb +33 -0
  18. data/lib/typesafe/questions/normalizer.rb +65 -0
  19. data/lib/typesafe/questions/noul.rb +36 -0
  20. data/lib/typesafe/questions/question.rb +48 -0
  21. data/lib/typesafe/questions/score.rb +32 -0
  22. data/lib/typesafe/request_options.rb +56 -0
  23. data/lib/typesafe/resources/models.rb +18 -0
  24. data/lib/typesafe/responses/answer.rb +143 -0
  25. data/lib/typesafe/responses/list_models_response.rb +93 -0
  26. data/lib/typesafe/responses/reader.rb +75 -0
  27. data/lib/typesafe/responses/system_one_response.rb +89 -0
  28. data/lib/typesafe/responses/usage.rb +37 -0
  29. data/lib/typesafe/retry_policy.rb +133 -0
  30. data/lib/typesafe/util.rb +156 -0
  31. data/lib/typesafe/version.rb +5 -0
  32. data/lib/typesafe-ai-ruby.rb +4 -0
  33. data/lib/typesafe.rb +114 -0
  34. data/sig/typesafe/http.rbs +77 -0
  35. data/sig/typesafe/questions.rbs +48 -0
  36. data/sig/typesafe/responses.rbs +131 -0
  37. data/sig/typesafe.rbs +261 -0
  38. metadata +97 -0
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module HTTP
5
+ # Default transport built on Net::HTTP with keep-alive connections managed per thread.
6
+ #
7
+ # Any object responding to call(request) and returning a Response can replace it,
8
+ # which is how tests stub the network and how alternative HTTP stacks can be plugged in.
9
+ class NetHTTPTransport
10
+ TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error].freeze
11
+ CONNECTION_ERRORS = [
12
+ SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError,
13
+ Net::HTTPBadResponse, Net::ProtocolError
14
+ ].freeze
15
+
16
+ def initialize
17
+ @managers = {}
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ def call(request)
22
+ manager = connection_manager
23
+ http = manager.connection_for(request.uri, timeout: request.timeout)
24
+ wrap_response(http.request(build_request(request)), request)
25
+ rescue *TIMEOUT_ERRORS => e
26
+ manager&.discard(request.uri)
27
+ raise timeout_error(request, e)
28
+ rescue *CONNECTION_ERRORS => e
29
+ manager&.discard(request.uri)
30
+ raise connection_error(request, e)
31
+ end
32
+
33
+ # Close every connection held for any thread.
34
+ def close
35
+ @mutex.synchronize do
36
+ @managers.each_value(&:clear)
37
+ @managers.clear
38
+ end
39
+ end
40
+
41
+ # The manager for the calling thread, creating it on first use and releasing
42
+ # managers whose threads have finished.
43
+ def connection_manager
44
+ @mutex.synchronize do
45
+ @managers.delete_if { |thread, manager| !thread.alive? && (manager.clear || true) }
46
+ @managers[Thread.current] ||= ConnectionManager.new
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def timeout_error(request, error)
53
+ APITimeoutError.new("#{request.endpoint} timed out after #{request.timeout}s (#{error.class})",
54
+ timeout: request.timeout)
55
+ end
56
+
57
+ def connection_error(request, error)
58
+ APIConnectionError.new("#{request.endpoint} failed: #{error.class}: #{error.message}")
59
+ end
60
+
61
+ def wrap_response(raw, request)
62
+ Response.new(status: raw.code.to_i, headers: raw.each_header.to_h, body: raw.body, request: request)
63
+ end
64
+
65
+ def build_request(request)
66
+ klass = request.method == :get ? Net::HTTP::Get : Net::HTTP::Post
67
+ raw = klass.new(request.uri.request_uri)
68
+ request.headers.each { |name, value| raw[name] = value }
69
+ raw.body = request.body if request.body
70
+ raw
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module HTTP
5
+ # An outgoing HTTP request: method, absolute URI, headers, encoded body and per-attempt timeout.
6
+ class Request
7
+ attr_reader :method, :uri, :headers, :body, :timeout
8
+
9
+ def initialize(method:, uri:, headers:, body:, timeout:)
10
+ @method = method
11
+ @uri = uri
12
+ @headers = headers.freeze
13
+ @body = body
14
+ @timeout = timeout
15
+ freeze
16
+ end
17
+
18
+ # Method and URL without credentials, query, or fragment.
19
+ def endpoint
20
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
21
+ "#{method.to_s.upcase} #{uri.scheme}://#{uri.host}#{port}#{uri.path}"
22
+ end
23
+
24
+ # A copy with additional headers merged in.
25
+ def with_headers(extra)
26
+ self.class.new(method: method, uri: uri, headers: headers.merge(extra), body: body, timeout: timeout)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module HTTP
5
+ # Builds requests from a Configuration, sends them through a transport with retries,
6
+ # logs them, and turns non-2xx responses into APIErrors.
7
+ class Requestor
8
+ PROTECTED_HEADERS = [
9
+ Constants::Headers::AUTHORIZATION, Constants::Headers::ACCEPT, Constants::Headers::USER_AGENT,
10
+ Constants::Headers::SDK, Constants::Headers::RUNTIME, Constants::Headers::RETRY_COUNT
11
+ ].map(&:downcase).freeze
12
+
13
+ attr_reader :config, :transport
14
+
15
+ # sleeper and clock exist so tests can drive the retry loop without waiting.
16
+ def initialize(config, transport: nil, sleeper: nil, clock: nil)
17
+ @config = config
18
+ @transport = transport || config.transport || NetHTTPTransport.new
19
+ @sleeper = sleeper
20
+ @clock = clock
21
+ end
22
+
23
+ # Send a POST and return the successful response.
24
+ def post(path, body:, options: RequestOptions.new)
25
+ execute(build_request(:post, path, body: body, options: options), options)
26
+ end
27
+
28
+ # Send a GET and return the successful response.
29
+ def get(path, options: RequestOptions.new)
30
+ execute(build_request(:get, path, options: options), options)
31
+ end
32
+
33
+ def close
34
+ transport.close if transport.respond_to?(:close)
35
+ end
36
+
37
+ private
38
+
39
+ def execute(request, options)
40
+ call = CallState.new(request)
41
+ Instrumentation.notify(:request_begin, call.begin_event)
42
+ response = retrier_for(options, call).run do |attempt|
43
+ send_once(attempt.zero? ? request : request.with_headers(Constants::Headers::RETRY_COUNT => attempt.to_s))
44
+ end
45
+ Instrumentation.notify(:request_end, call.end_event(response: response))
46
+ response
47
+ rescue APIError, APIConnectionError => e
48
+ Instrumentation.notify(:request_end, call.end_event(error: e))
49
+ raise
50
+ end
51
+
52
+ def retrier_for(options, call)
53
+ on_retry = lambda do |error, attempt, delay|
54
+ call.retried!
55
+ log_retry(error, attempt, delay)
56
+ end
57
+ Retrier.new(retry_policy_for(options), sleeper: @sleeper, clock: @clock, on_retry: on_retry)
58
+ end
59
+
60
+ # Bookkeeping for one client call across its attempts, feeding the instrumentation events.
61
+ class CallState
62
+ attr_reader :begin_event
63
+
64
+ def initialize(request)
65
+ @request = request
66
+ @started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ @num_retries = 0
68
+ @begin_event = Instrumentation::RequestBeginEvent.new(method: request.method, path: request.uri.path)
69
+ end
70
+
71
+ def retried!
72
+ @num_retries += 1
73
+ end
74
+
75
+ def end_event(response: nil, error: nil)
76
+ status = response&.status || (error.status if error.is_a?(APIError))
77
+ request_id = response&.request_id || (error.request_id if error.is_a?(APIError))
78
+ Instrumentation::RequestEndEvent.new(
79
+ method: @request.method, path: @request.uri.path, http_status: status,
80
+ duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started,
81
+ num_retries: @num_retries, request_id: request_id, error: error, user_data: @begin_event.user_data
82
+ )
83
+ end
84
+ end
85
+
86
+ def send_once(request)
87
+ log_request(request)
88
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
89
+ response = transport.call(request)
90
+ log_response(request, response, started)
91
+ raise APIError.from_response(response) unless response.success?
92
+
93
+ response
94
+ end
95
+
96
+ def retry_policy_for(options)
97
+ RetryPolicy.from(options.retry_policy, base: config.retry_policy)
98
+ end
99
+
100
+ def build_request(method, path, body: nil, options: RequestOptions.new)
101
+ encoded = body.nil? ? nil : JSON.generate(body)
102
+ Request.new(
103
+ method: method,
104
+ uri: URI.parse("#{config.base_url}#{path}"),
105
+ headers: build_headers(options.headers, body: encoded),
106
+ body: encoded,
107
+ timeout: options.timeout || config.timeout
108
+ )
109
+ end
110
+
111
+ def build_headers(extra, body:)
112
+ headers = strip_protected(config.headers).merge(strip_protected(extra))
113
+ headers[Constants::Headers::AUTHORIZATION] = "Bearer #{config.api_key}"
114
+ headers[Constants::Headers::ACCEPT] = Constants::JSON_CONTENT_TYPE
115
+ headers[Constants::Headers::USER_AGENT] = sdk_identifier
116
+ headers[Constants::Headers::SDK] = sdk_identifier
117
+ headers[Constants::Headers::RUNTIME] = Util.runtime
118
+ headers[Constants::Headers::CONTENT_TYPE] = Constants::JSON_CONTENT_TYPE if body
119
+ headers
120
+ end
121
+
122
+ def strip_protected(headers)
123
+ headers.reject { |name, _| PROTECTED_HEADERS.include?(name.downcase) }
124
+ end
125
+
126
+ def sdk_identifier
127
+ "#{Constants::SDK_NAME}/#{VERSION}"
128
+ end
129
+
130
+ def log_request(request)
131
+ Logging.debug(config) do
132
+ "-> #{request.endpoint} headers={#{Logging.format_headers(request.headers)}} body=#{request.body.inspect}"
133
+ end
134
+ end
135
+
136
+ def log_response(request, response, started)
137
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
138
+ Logging.info(config) do
139
+ "#{request.endpoint} -> #{response.status} (#{elapsed}ms) request_id=#{response.request_id || "-"}"
140
+ end
141
+ Logging.debug(config) do
142
+ "<- #{response.status} headers={#{Logging.format_headers(response.headers)}} body=#{response.body.inspect}"
143
+ end
144
+ end
145
+
146
+ def log_retry(error, attempt, delay)
147
+ Logging.info(config) do
148
+ "retry #{attempt + 1} in #{delay.round(3)}s after #{error.class.name.split("::").last}: #{error.message}"
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module HTTP
5
+ # A raw HTTP response: status, lower-cased headers, body text, and the request that produced it.
6
+ class Response
7
+ attr_reader :status, :headers, :body, :request
8
+
9
+ def initialize(status:, headers:, body:, request: nil)
10
+ @status = status
11
+ @headers = Util.normalize_headers(headers).freeze
12
+ @body = body.to_s
13
+ @request = request
14
+ @json = nil
15
+ @json_parsed = false
16
+ end
17
+
18
+ # Case-insensitive header lookup.
19
+ def [](name)
20
+ headers[name.to_s.downcase]
21
+ end
22
+
23
+ # The x-typesafe-request-id header.
24
+ def request_id
25
+ self[Constants::Headers::REQUEST_ID]
26
+ end
27
+
28
+ def success?
29
+ (200..299).cover?(status)
30
+ end
31
+
32
+ # The parsed JSON body, or nil when the body is not valid JSON.
33
+ def json
34
+ return @json if @json_parsed
35
+
36
+ @json_parsed = true
37
+ @json = body.empty? ? nil : JSON.parse(body)
38
+ rescue JSON::ParserError
39
+ @json = nil
40
+ end
41
+
42
+ # Server-requested wait in seconds, from retry-after-ms or Retry-After.
43
+ def retry_after
44
+ Util.parse_retry_after(headers)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module HTTP
5
+ # Runs a block until it succeeds or the RetryPolicy gives up. The block receives the
6
+ # attempt number (0 for the first try) and raises APIError or APIConnectionError on failure.
7
+ # The last error is re-raised when no more retries are allowed or the total budget
8
+ # would be exceeded by the next delay.
9
+ class Retrier
10
+ MONOTONIC = Process::CLOCK_MONOTONIC
11
+
12
+ attr_reader :policy
13
+
14
+ def initialize(policy, sleeper: nil, clock: nil, random: Kernel, on_retry: nil)
15
+ @policy = policy
16
+ @sleeper = sleeper || Kernel.method(:sleep)
17
+ @clock = clock || -> { Process.clock_gettime(MONOTONIC) }
18
+ @random = random
19
+ @on_retry = on_retry
20
+ end
21
+
22
+ def run
23
+ started = @clock.call
24
+ attempt = 0
25
+ loop do
26
+ return yield(attempt)
27
+ rescue APIError, APIConnectionError => e
28
+ delay = retry_delay(e, attempt, started)
29
+ raise if delay.nil?
30
+
31
+ @on_retry&.call(e, attempt, delay)
32
+ @sleeper.call(delay)
33
+ attempt += 1
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def retry_delay(error, attempt, started)
40
+ return nil unless attempt < policy.max_retries && retryable?(error)
41
+
42
+ delay = policy.delay_for(attempt, headers: error.respond_to?(:headers) ? error.headers : nil, random: @random)
43
+ return nil if policy.timeout && (@clock.call - started + delay) >= policy.timeout
44
+
45
+ delay
46
+ end
47
+
48
+ def retryable?(error)
49
+ case error
50
+ when APIError then policy.retry_status?(error.status)
51
+ when APIConnectionError then policy.retry_error?(error)
52
+ else false
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ # Hooks for observing requests, in the spirit of stripe-ruby's Instrumentation.
5
+ #
6
+ # TypeSafe::Instrumentation.subscribe(:request_end) do |event|
7
+ # StatsD.timing("typesafe.request", event.duration, tags: ["status:#{event.http_status}"])
8
+ # end
9
+ #
10
+ # :request_begin fires once per client call with a RequestBeginEvent; :request_end fires
11
+ # once per call after any retries with a RequestEndEvent, whether the call succeeded or
12
+ # raised. Anything stored in the begin event's user_data is available on the end event.
13
+ module Instrumentation
14
+ TOPICS = %i[request_begin request_end].freeze
15
+
16
+ class RequestBeginEvent
17
+ attr_reader :method, :path, :user_data
18
+
19
+ def initialize(method:, path:)
20
+ @method = method
21
+ @path = path
22
+ @user_data = {}
23
+ end
24
+ end
25
+
26
+ class RequestEndEvent
27
+ # HTTP method as a Symbol and the request path.
28
+ attr_reader :method, :path
29
+ # Final status code, or nil when no response was received.
30
+ attr_reader :http_status
31
+ # Wall time in seconds for the whole call including retries.
32
+ attr_reader :duration
33
+ # Retries performed after the first attempt.
34
+ attr_reader :num_retries
35
+ # The x-typesafe-request-id of the final response, when present.
36
+ attr_reader :request_id
37
+ # The error raised to the caller, or nil on success.
38
+ attr_reader :error
39
+ # The Hash from the matching RequestBeginEvent.
40
+ attr_reader :user_data
41
+
42
+ def initialize(method:, path:, http_status:, duration:, num_retries:, request_id:, error:, user_data:)
43
+ @method = method
44
+ @path = path
45
+ @http_status = http_status
46
+ @duration = duration
47
+ @num_retries = num_retries
48
+ @request_id = request_id
49
+ @error = error
50
+ @user_data = user_data
51
+ freeze
52
+ end
53
+ end
54
+
55
+ @subscribers = TOPICS.to_h { |topic| [topic, {}] }
56
+ @mutex = Mutex.new
57
+
58
+ class << self
59
+ # Register a block for a topic. Returns the subscription name, which can be
60
+ # passed to unsubscribe. Subscribing again with the same name replaces the block.
61
+ def subscribe(topic, name = Object.new, &block)
62
+ raise ArgumentError, "unknown topic #{topic.inspect}; expected one of #{TOPICS.join(", ")}" unless
63
+ TOPICS.include?(topic)
64
+ raise ArgumentError, "a block is required" unless block
65
+
66
+ @mutex.synchronize { @subscribers[topic][name] = block }
67
+ name
68
+ end
69
+
70
+ def unsubscribe(topic, name)
71
+ @mutex.synchronize { @subscribers.fetch(topic).delete(name) }
72
+ end
73
+
74
+ def notify(topic, event)
75
+ @mutex.synchronize { @subscribers.fetch(topic).values }.each { |block| block.call(event) }
76
+ end
77
+
78
+ # Remove every subscriber. Mostly useful in tests.
79
+ def reset!
80
+ @mutex.synchronize { @subscribers.each_value(&:clear) }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ # Logging helpers. The client logs request summaries at info and full headers and bodies
5
+ # at debug. Credential headers are redacted; bodies are not, so keep debug logging out of
6
+ # production if state contains sensitive data.
7
+ module Logging
8
+ PROGNAME = "typesafe"
9
+ REDACTED = "[REDACTED]"
10
+ REDACTED_HEADERS = %w[authorization proxy-authorization x-api-key].freeze
11
+
12
+ module_function
13
+
14
+ def log?(config, severity)
15
+ !config.logger.nil? && config.logger_severity <= severity
16
+ end
17
+
18
+ def info(config, &)
19
+ config.logger.info(PROGNAME, &) if log?(config, Logger::INFO)
20
+ end
21
+
22
+ def debug(config, &)
23
+ config.logger.debug(PROGNAME, &) if log?(config, Logger::DEBUG)
24
+ end
25
+
26
+ # A copy of the headers with credential values replaced.
27
+ def redact_headers(headers)
28
+ headers.to_h do |name, value|
29
+ [name, REDACTED_HEADERS.include?(name.to_s.downcase) ? REDACTED : value]
30
+ end
31
+ end
32
+
33
+ def format_headers(headers)
34
+ redact_headers(headers).map { |name, value| "#{name}: #{value}" }.join(", ")
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module Questions
5
+ # Picks one option from a set you define. The answer carries the chosen option,
6
+ # a probability per option, and a confidence.
7
+ #
8
+ # For example:
9
+ # TypeSafe::Choice.new(
10
+ # instructions: "What is the tone?",
11
+ # criteria: { calm: nil, frustrated: nil, angry: "Shouting, threats, or profanity" }
12
+ # )
13
+ class Choice < Question
14
+ TYPE = "choice"
15
+
16
+ def initialize(criteria:, instructions: nil)
17
+ super(TYPE, instructions: instructions, criteria: self.class.normalize_criteria(criteria))
18
+ end
19
+
20
+ def self.normalize_criteria(criteria)
21
+ raise ValidationError, "Choice criteria must be a non-empty Hash of option => description" unless
22
+ criteria.is_a?(Hash) && !criteria.empty?
23
+
24
+ criteria.each_with_object({}) do |(key, value), result|
25
+ name = Util.stringify_key(key)
26
+ raise ValidationError, "Choice option names must not be blank" if name.strip.empty?
27
+
28
+ result[name] = value.nil? ? nil : Util.deep_stringify(value)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module Questions
5
+ # Turns user-supplied state and questions into the wire format, validating the obvious
6
+ # mistakes before a request is sent.
7
+ module Normalizer
8
+ TYPES = {
9
+ Noul::TYPE => Noul, Choice::TYPE => Choice, Score::TYPE => Score
10
+ }.freeze
11
+
12
+ module_function
13
+
14
+ def state(state)
15
+ raise ValidationError, "state must not be nil; pass a String, Hash, or Array" if state.nil?
16
+
17
+ value = Util.deep_stringify(state)
18
+ return value if value.is_a?(String) || value.is_a?(Hash) || value.is_a?(Array)
19
+
20
+ raise ValidationError, "state must be a String, Hash, or Array, got #{state.class}"
21
+ end
22
+
23
+ # Questions in wire format.
24
+ def questions(questions)
25
+ raise ValidationError, "questions must be a non-empty Hash" unless questions.is_a?(Hash) && !questions.empty?
26
+
27
+ questions.each_with_object({}) do |(name, question), result|
28
+ key = Util.stringify_key(name)
29
+ raise ValidationError, "question names must not be blank" if key.strip.empty?
30
+
31
+ result[key] = question(question, key)
32
+ end
33
+ end
34
+
35
+ def question(question, name)
36
+ case question
37
+ when Question then question.to_h
38
+ when Hash then raw_question(question, name)
39
+ else
40
+ raise ValidationError,
41
+ "question #{name.inspect} must be a TypeSafe::Noul, Choice, Score, or a Hash with a \"type\" key"
42
+ end
43
+ end
44
+
45
+ # Validate a raw Hash question. Known types get the same checks as the typed classes;
46
+ # unknown types and extra keys pass through untouched so new API fields work immediately.
47
+ def raw_question(hash, name)
48
+ wire = Util.deep_stringify(hash)
49
+ type = wire["type"]
50
+ unless type.is_a?(String) && !type.empty?
51
+ raise ValidationError,
52
+ "question #{name.inspect} is missing a \"type\""
53
+ end
54
+
55
+ klass = TYPES[type]
56
+ wire["criteria"] = klass.normalize_criteria(wire["criteria"]) if klass && requires_criteria?(klass, wire)
57
+ wire
58
+ end
59
+
60
+ def requires_criteria?(klass, wire)
61
+ klass != Noul || wire.key?("criteria")
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module Questions
5
+ # A yes/no question. The answer is the probability of yes, from 0 to 1.
6
+ #
7
+ # For example:
8
+ # TypeSafe::Noul.new(
9
+ # instructions: "Is this message spam?",
10
+ # criteria: { true: "Unsolicited advertising", false: "A real conversation" }
11
+ # )
12
+ class Noul < Question
13
+ TYPE = "noul"
14
+ KEYS = %w[true false].freeze
15
+
16
+ def initialize(instructions: nil, criteria: nil)
17
+ super(TYPE, instructions: instructions, criteria: self.class.normalize_criteria(criteria))
18
+ end
19
+
20
+ def self.normalize_criteria(criteria)
21
+ return nil if criteria.nil?
22
+ raise ValidationError, "Noul criteria must be a Hash with true/false keys" unless criteria.is_a?(Hash)
23
+
24
+ criteria.each_with_object({}) do |(key, value), result|
25
+ name = key.to_s
26
+ unless KEYS.include?(name)
27
+ raise ValidationError,
28
+ "Noul criteria keys must be true or false, got #{key.inspect}"
29
+ end
30
+
31
+ result[name] = value.nil? ? nil : Util.deep_stringify(value)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module Questions
5
+ # Base class for the three typed questions. Instances are immutable value objects
6
+ # that serialize to the wire format through #to_h.
7
+ class Question
8
+ # "noul", "choice" or "score".
9
+ attr_reader :type
10
+ # What the model should decide.
11
+ attr_reader :instructions
12
+ # Type-specific rubric.
13
+ attr_reader :criteria
14
+
15
+ def initialize(type, instructions:, criteria:)
16
+ @type = type
17
+ @instructions = instructions.nil? ? nil : Util.deep_stringify(instructions)
18
+ @criteria = criteria
19
+ freeze
20
+ end
21
+
22
+ # The request body fragment for this question.
23
+ def to_h
24
+ hash = { "type" => type }
25
+ hash["instructions"] = instructions unless instructions.nil?
26
+ hash["criteria"] = criteria unless criteria.nil?
27
+ hash
28
+ end
29
+
30
+ def to_json(*args)
31
+ JSON.generate(to_h, *args)
32
+ end
33
+
34
+ def ==(other)
35
+ other.is_a?(Question) && to_h == other.to_h
36
+ end
37
+ alias eql? ==
38
+
39
+ def hash
40
+ to_h.hash
41
+ end
42
+
43
+ def inspect
44
+ "#<#{self.class.name} #{to_h.inspect}>"
45
+ end
46
+ end
47
+ end
48
+ end