hindsight-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.
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'json'
5
+ require 'uri'
6
+ require 'faraday'
7
+ require 'faraday/multipart'
8
+ require_relative 'errors'
9
+ require_relative 'option_validation'
10
+
11
+ module Hindsight
12
+ class Client
13
+ ALLOWED_HTTP_METHODS = %i[get post put patch delete head options].freeze
14
+ FEATURE_PROBE_FAILURE_COOLDOWN_SECONDS = 5.0
15
+ LOGGER_SENSITIVE_HEADERS = %w[
16
+ Authorization
17
+ Proxy-Authorization
18
+ Cookie
19
+ Set-Cookie
20
+ X-Api-Key
21
+ X-Auth-Token
22
+ Api-Key
23
+ X-Webhook-Secret
24
+ ].freeze
25
+
26
+ attr_reader :base_url
27
+
28
+ def initialize(
29
+ base_url,
30
+ api_key: nil,
31
+ headers: nil,
32
+ open_timeout: 10,
33
+ timeout: 30,
34
+ logger: nil,
35
+ connection: nil,
36
+ allow_insecure: false
37
+ )
38
+ @base_url = OptionValidation.normalize_non_empty_string(base_url, key: :base_url)
39
+ validate_url_scheme!
40
+ @api_key = OptionValidation.normalize_optional_non_empty_string(api_key, key: :api_key)
41
+ @default_headers = normalize_headers(headers)
42
+ @default_headers['Authorization'] = "Bearer #{@api_key}" if @api_key
43
+ @open_timeout = open_timeout
44
+ @timeout = timeout
45
+ @logger = logger
46
+ @allow_insecure = OptionValidation.normalize_boolean(allow_insecure, key: :allow_insecure)
47
+ validate_insecure_transport!
48
+ warn_insecure_transport
49
+ @connection = connection || build_connection
50
+ @feature_mutex = Mutex.new
51
+ @feature_probe_error_until = nil
52
+ end
53
+
54
+ def marshal_dump
55
+ raise TypeError, "#{self.class} cannot be serialized (contains credentials)"
56
+ end
57
+
58
+ def inspect
59
+ "#<#{self.class} base_url=#{@base_url.inspect} api_key=[REDACTED]>"
60
+ end
61
+
62
+ def bank(bank_id)
63
+ Bank.new(client: self, bank_id: bank_id)
64
+ end
65
+
66
+ def banks
67
+ @banks ||= Resources::Banks.new(client: self, base_path: '/v1/default/banks')
68
+ end
69
+
70
+ def chunks
71
+ @chunks ||= Resources::Chunks.new(client: self, base_path: '/v1/default')
72
+ end
73
+
74
+ def version
75
+ request_json(:get, '/version')
76
+ end
77
+
78
+ def health
79
+ request_json(:get, '/health')
80
+ end
81
+
82
+ def features
83
+ payload = version
84
+ payload['features'] || {}
85
+ rescue APIError => e
86
+ raise if auth_api_error?(e)
87
+
88
+ {}
89
+ end
90
+
91
+ def file_upload_api_supported?
92
+ @feature_mutex.synchronize do
93
+ return @file_upload_api_supported unless @file_upload_api_supported.nil?
94
+ return nil if @feature_probe_error_until && monotonic_now < @feature_probe_error_until
95
+
96
+ payload = version
97
+ value = (payload['features'] || {})['file_upload_api']
98
+ # Cache false when the flag is missing to avoid repeated /version calls.
99
+ @file_upload_api_supported = (value == true)
100
+ @feature_probe_error_until = nil
101
+ @file_upload_api_supported
102
+ rescue APIError => e
103
+ raise if auth_api_error?(e)
104
+
105
+ # Treat API failures as unknown, and retry on next call.
106
+ nil
107
+ rescue TimeoutError, ConnectionError
108
+ @feature_probe_error_until = monotonic_now + FEATURE_PROBE_FAILURE_COOLDOWN_SECONDS
109
+ nil
110
+ end
111
+ end
112
+
113
+ def retain_json(bank_path, payload)
114
+ request_json(:post, "#{bank_path}/memories", payload)
115
+ end
116
+
117
+ def request_json(method, path, payload = nil, query: nil)
118
+ validate_http_method!(method)
119
+ wrap_faraday_errors do
120
+ response = connection.public_send(method, path) do |req|
121
+ req.headers.update(@default_headers) unless @default_headers.empty?
122
+ configure_request_options(req)
123
+ req.params.update(query) if query && !query.empty?
124
+ req.body = payload unless payload.nil?
125
+ end
126
+
127
+ validate_json_body(response)
128
+ end
129
+ end
130
+
131
+ def request_multipart_files(method, path, uploads, extra_fields: {})
132
+ validate_http_method!(method)
133
+ payload = extra_fields.merge(
134
+ files: uploads.map { |u| Faraday::Multipart::FilePart.new(u[:io], u[:content_type], u[:filename]) }
135
+ )
136
+ wrap_faraday_errors do
137
+ response = connection.public_send(method, path) do |req|
138
+ req.headers.update(@default_headers) unless @default_headers.empty?
139
+ configure_request_options(req)
140
+ req.body = payload
141
+ end
142
+
143
+ validate_json_body(response)
144
+ end
145
+ end
146
+
147
+ def escape(value)
148
+ CGI.escape(value.to_s).gsub('+', '%20')
149
+ end
150
+
151
+ private
152
+
153
+ attr_reader :connection
154
+
155
+ def build_connection
156
+ Faraday.new(url: base_url) do |faraday|
157
+ faraday.options.params_encoder = Faraday::FlatParamsEncoder
158
+ faraday.headers.update(@default_headers) unless @default_headers.empty?
159
+ # Send repeated multipart keys as `files`, not `files[]`.
160
+ faraday.request :multipart, flat_encode: true
161
+ faraday.request :json
162
+ faraday.response :json, content_type: /json/
163
+ if @logger
164
+ faraday.response :logger, @logger, headers: true, bodies: false do |log|
165
+ sensitive_log_header_names.each do |header_name|
166
+ log.filter(/^(\s*#{Regexp.escape(header_name)}:\s*)(.*)$/i, '\1[REDACTED]')
167
+ end
168
+ end
169
+ end
170
+ faraday.response :raise_error
171
+ faraday.adapter Faraday.default_adapter
172
+ end
173
+ end
174
+
175
+ def wrap_faraday_errors
176
+ yield
177
+ rescue Faraday::TimeoutError => e
178
+ raise TimeoutError, "Hindsight request timed out: #{e.message}"
179
+ rescue Faraday::ConnectionFailed => e
180
+ raise ConnectionError, "Hindsight connection failed: #{e.message}"
181
+ rescue Faraday::Error => e
182
+ raise_api_error(e)
183
+ end
184
+
185
+ def configure_request_options(req)
186
+ req.options.open_timeout = @open_timeout if @open_timeout
187
+ req.options.timeout = @timeout if @timeout
188
+ end
189
+
190
+ def validate_http_method!(method)
191
+ return if method.respond_to?(:to_sym) && ALLOWED_HTTP_METHODS.include?(method.to_sym)
192
+
193
+ raise ValidationError,
194
+ "Invalid HTTP method: #{method.inspect}. Expected one of #{ALLOWED_HTTP_METHODS.join(', ')}"
195
+ end
196
+
197
+ def normalize_headers(value)
198
+ return {} if value.nil?
199
+ raise ValidationError, "Invalid headers: #{value.inspect}. Expected a Hash" unless value.is_a?(Hash)
200
+
201
+ value.each_with_object({}) do |(key, header_value), headers|
202
+ raw_name = String(key)
203
+ raise ValidationError, "Invalid header name: #{raw_name.inspect}" if raw_name.match?(/[\r\n\0]/)
204
+
205
+ name = raw_name.strip
206
+ raise ValidationError, 'headers keys must not be empty' if name.empty?
207
+
208
+ str_value = String(header_value)
209
+ raise ValidationError, "Invalid header value for #{name}" if str_value.match?(/[\r\n\0]/)
210
+
211
+ headers[name] = str_value
212
+ rescue TypeError
213
+ raise ValidationError,
214
+ "Invalid header #{key.inspect}: #{header_value.inspect}. Expected string-like key/value"
215
+ end
216
+ end
217
+
218
+ def validate_json_body(response)
219
+ body = response.body
220
+ return body if body.is_a?(Hash)
221
+ return {} if body.nil?
222
+
223
+ raise APIError.new(
224
+ "Unexpected response: expected JSON object, got #{body.class}. " \
225
+ 'If you injected a custom connection:, ensure it includes the :json response middleware.',
226
+ status: response.status,
227
+ body: body
228
+ )
229
+ end
230
+
231
+ def raise_api_error(error)
232
+ status = error.response && error.response[:status]
233
+ body = error.response && error.response[:body]
234
+ status_label = status.nil? ? 'unknown' : status
235
+ raise APIError.new(
236
+ "Hindsight API error (status #{status_label})",
237
+ status: status,
238
+ body: body
239
+ )
240
+ end
241
+
242
+ def validate_url_scheme!
243
+ raise ValidationError, 'base_url contains invalid characters' if @base_url.match?(/[\r\n\0]/)
244
+ uri = URI.parse(@base_url)
245
+ has_http_scheme = uri.is_a?(URI::HTTP)
246
+ has_host = !uri.host.to_s.strip.empty?
247
+ return if has_http_scheme && has_host
248
+
249
+ raise ValidationError, "base_url must be a valid http(s) URL with a host, got: #{@base_url.inspect}"
250
+ rescue URI::InvalidURIError
251
+ raise ValidationError, "base_url must be a valid http(s) URL with a host, got: #{@base_url.inspect}"
252
+ end
253
+
254
+ def validate_insecure_transport!
255
+ return unless insecure_transport_with_credentials?
256
+ return if @allow_insecure || localhost_base_url?
257
+
258
+ raise ValidationError,
259
+ 'Refusing to send credentials over insecure http:// transport. ' \
260
+ 'Use https:// or pass allow_insecure: true to override.'
261
+ end
262
+
263
+ def warn_insecure_transport
264
+ return unless insecure_transport_with_credentials?
265
+
266
+ warn '[Hindsight] WARNING: Credentials detected with an http:// base_url. ' \
267
+ 'Credentials will be sent in plain text. Use https:// in production.'
268
+ end
269
+
270
+ def insecure_transport_with_credentials?
271
+ @base_url.match?(%r{\Ahttp://}i) &&
272
+ (@api_key || @default_headers&.keys&.any? { |k| k.match?(/\Aauthorization\z/i) })
273
+ end
274
+
275
+ def localhost_base_url?
276
+ host = URI.parse(@base_url).host&.downcase
277
+ %w[localhost 127.0.0.1 ::1].include?(host)
278
+ rescue URI::InvalidURIError
279
+ false
280
+ end
281
+
282
+ def auth_api_error?(error)
283
+ [401, 403].include?(Integer(error.status))
284
+ rescue ArgumentError, TypeError
285
+ false
286
+ end
287
+
288
+ def sensitive_log_header_names
289
+ (LOGGER_SENSITIVE_HEADERS + @default_headers.keys).uniq
290
+ end
291
+
292
+ def monotonic_now
293
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hindsight
4
+ class Error < StandardError
5
+ def retriable? = false
6
+ end
7
+
8
+ class ValidationError < Error; end
9
+
10
+ class ConnectionError < Error
11
+ def retriable? = true
12
+ end
13
+
14
+ class TimeoutError < Error
15
+ def retriable? = true
16
+ end
17
+
18
+ class FeatureNotSupported < Error; end
19
+
20
+ class APIError < Error
21
+ attr_reader :status, :body
22
+
23
+ def initialize(message, status: nil, body: nil)
24
+ @status = status
25
+ @body = body
26
+ super(message)
27
+ end
28
+
29
+ def retriable?
30
+ return true if status.nil?
31
+
32
+ code = status.to_i
33
+ code >= 500 || code == 429
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module Hindsight
6
+ module OptionValidation
7
+ module_function
8
+
9
+ VALID_BUDGETS = %i[low mid high].freeze
10
+ VALID_FACT_TYPES = %i[world experience observation].freeze
11
+ VALID_TAG_MATCH_MODES = %i[any all exact all_strict].freeze
12
+ VALID_EXTRACTION_MODES = %i[concise verbose custom].freeze
13
+
14
+ def normalize_enum(value, key:, valid:)
15
+ symbol = normalize_symbol(value, key: key)
16
+ return symbol if valid.include?(symbol)
17
+
18
+ raise ValidationError,
19
+ "Invalid #{key}: #{value.inspect}. Expected one of #{valid.join(', ')}"
20
+ end
21
+
22
+ def normalize_budget(value)
23
+ normalize_enum(value, key: :budget, valid: VALID_BUDGETS)
24
+ end
25
+
26
+ def normalize_symbol(value, key:)
27
+ return nil if value.nil?
28
+ return value.to_sym if value.respond_to?(:to_sym)
29
+
30
+ raise ValidationError, "Invalid #{key}: #{value.inspect}. Expected symbol or string"
31
+ end
32
+
33
+ def normalize_positive_integer(value, key:)
34
+ int = Integer(value)
35
+ return int if int.positive?
36
+
37
+ raise ValidationError, "Invalid #{key}: #{value.inspect}. Expected a positive integer"
38
+ rescue ArgumentError, TypeError
39
+ raise ValidationError, "Invalid #{key}: #{value.inspect}. Expected a positive integer"
40
+ end
41
+
42
+ def normalize_non_negative_integer(value, key:)
43
+ int = Integer(value)
44
+ return int if int >= 0
45
+
46
+ raise ValidationError, "Invalid #{key}: #{value.inspect}. Expected a non-negative integer"
47
+ rescue ArgumentError, TypeError
48
+ raise ValidationError, "Invalid #{key}: #{value.inspect}. Expected a non-negative integer"
49
+ end
50
+
51
+ def normalize_integer_in_range(value, key:, minimum:, maximum:)
52
+ int = Integer(value)
53
+ return int if int.between?(minimum, maximum)
54
+
55
+ raise ValidationError, "Invalid #{key}: #{value.inspect}. Expected an integer between #{minimum} and #{maximum}"
56
+ rescue ArgumentError, TypeError
57
+ raise ValidationError, "Invalid #{key}: #{value.inspect}. Expected an integer between #{minimum} and #{maximum}"
58
+ end
59
+
60
+ def normalize_positive_number(value, key:)
61
+ number = Float(value)
62
+ return number if number.positive?
63
+
64
+ raise ValidationError, "Invalid #{key}: #{value.inspect}. Expected a positive number"
65
+ rescue ArgumentError, TypeError
66
+ raise ValidationError, "Invalid #{key}: #{value.inspect}. Expected a positive number"
67
+ end
68
+
69
+ def normalize_tags(value)
70
+ return nil if value.nil?
71
+ raise ValidationError, "Invalid tags: #{value.inspect}. Expected an Array" unless value.is_a?(Array)
72
+
73
+ value.map do |tag|
74
+ unless tag.is_a?(String) || tag.is_a?(Symbol)
75
+ raise ValidationError, "Invalid tag: #{tag.inspect}. Expected a String or Symbol"
76
+ end
77
+
78
+ tag.to_s
79
+ end
80
+ end
81
+
82
+ def normalize_hash(value, key:)
83
+ return nil if value.nil?
84
+ return value if value.is_a?(Hash)
85
+
86
+ raise ValidationError, "Invalid #{key}: #{value.inspect}. Expected a Hash"
87
+ end
88
+
89
+ def normalize_include_options(value, key: :include)
90
+ include_options = normalize_hash(value, key: key)
91
+ return nil if include_options.nil?
92
+
93
+ include_options.each_with_object({}) do |(raw_name, raw_options), normalized|
94
+ name = raw_name.to_s
95
+
96
+ case raw_options
97
+ when nil, false
98
+ next
99
+ when true
100
+ normalized[name] = {}
101
+ when Hash
102
+ normalized[name] = raw_options
103
+ else
104
+ raise ValidationError,
105
+ "Invalid #{key}.#{name}: #{raw_options.inspect}. Expected true, false, nil, or a Hash"
106
+ end
107
+ end
108
+ end
109
+
110
+ def normalize_fact_type(value, key: :type)
111
+ return nil if value.nil?
112
+
113
+ normalize_enum(value, key: key, valid: VALID_FACT_TYPES)
114
+ end
115
+
116
+ def normalize_fact_types(value, key: :types)
117
+ return nil if value.nil?
118
+
119
+ raise ValidationError, "Invalid #{key}: #{value.inspect}. Expected an Array" unless value.is_a?(Array)
120
+
121
+ value.map do |item|
122
+ normalize_enum(item, key: key, valid: VALID_FACT_TYPES).to_s
123
+ end
124
+ end
125
+
126
+ def normalize_non_empty_string(value, key:)
127
+ string = String(value).strip
128
+ return string unless string.empty?
129
+
130
+ raise ValidationError, "#{key} must not be empty"
131
+ rescue TypeError
132
+ raise ValidationError, "Invalid #{key}: #{value.inspect}. Expected a string-like value"
133
+ end
134
+
135
+ def normalize_optional_non_empty_string(value, key:)
136
+ return nil if value.nil?
137
+
138
+ normalize_non_empty_string(value, key: key)
139
+ end
140
+
141
+ def normalize_optional_positive_integer(value, key:)
142
+ return nil if value.nil?
143
+
144
+ normalize_positive_integer(value, key: key)
145
+ end
146
+
147
+ def normalize_optional_non_negative_integer(value, key:)
148
+ return nil if value.nil?
149
+
150
+ normalize_non_negative_integer(value, key: key)
151
+ end
152
+
153
+ def normalize_optional_integer(value, key:)
154
+ return nil if value.nil?
155
+
156
+ Integer(value)
157
+ rescue ArgumentError, TypeError
158
+ raise ValidationError, "Invalid #{key}: #{value.inspect}. Expected an integer"
159
+ end
160
+
161
+ def normalize_required_hash(value, key:)
162
+ hash = normalize_hash(value, key: key)
163
+ return hash if hash
164
+
165
+ raise ValidationError, "#{key} must not be nil"
166
+ end
167
+
168
+ def normalize_boolean(value, key:)
169
+ return value if [true, false].include?(value)
170
+
171
+ raise ValidationError, "Invalid #{key}: #{value.inspect}. Expected true or false"
172
+ end
173
+
174
+ def normalize_optional_boolean(value, key:)
175
+ return nil if value.nil?
176
+
177
+ normalize_boolean(value, key: key)
178
+ end
179
+
180
+ def normalize_tags_match(value, key: :tags_match)
181
+ return nil if value.nil?
182
+
183
+ normalize_enum(value, key: key, valid: VALID_TAG_MATCH_MODES)
184
+ end
185
+
186
+ def normalize_extraction_mode(value, key: :retain_extraction_mode)
187
+ return nil if value.nil?
188
+
189
+ normalize_enum(value, key: key, valid: VALID_EXTRACTION_MODES)
190
+ end
191
+
192
+ def normalize_entities(value)
193
+ return nil if value.nil?
194
+ raise ValidationError, "Invalid entities: #{value.inspect}. Expected an Array" unless value.is_a?(Array)
195
+
196
+ value.each do |entity|
197
+ raise ValidationError, "Invalid entity: #{entity.inspect}. Expected a Hash" unless entity.is_a?(Hash)
198
+
199
+ text = entity[:text] || entity['text']
200
+ raise ValidationError, 'Each entity must have a :text key' if text.nil? || String(text).strip.empty?
201
+ end
202
+ value
203
+ end
204
+
205
+ def normalize_files_metadata(value)
206
+ return nil if value.nil?
207
+ raise ValidationError, "Invalid files_metadata: #{value.inspect}. Expected an Array" unless value.is_a?(Array)
208
+
209
+ value.each do |entry|
210
+ unless entry.is_a?(Hash)
211
+ raise ValidationError,
212
+ "Invalid files_metadata entry: #{entry.inspect}. Expected a Hash"
213
+ end
214
+ end
215
+ value
216
+ end
217
+
218
+ def normalize_query(value, key: :query)
219
+ string = String(value)
220
+ return string unless string.strip.empty?
221
+
222
+ raise ValidationError, "#{key} must not be empty"
223
+ end
224
+
225
+ def normalize_response_schema(value, key: :response_schema)
226
+ return nil if value.nil?
227
+ return value if value.is_a?(Hash)
228
+ return value.to_json_schema if value.respond_to?(:to_json_schema)
229
+
230
+ raise ValidationError,
231
+ "Invalid #{key}: expected a Hash or object responding to #to_json_schema"
232
+ end
233
+
234
+ def normalize_disposition(value, key: :disposition)
235
+ disposition = normalize_hash(value, key: key)
236
+ return nil if disposition.nil?
237
+
238
+ skepticism = disposition[:skepticism] || disposition['skepticism']
239
+ literalism = disposition[:literalism] || disposition['literalism']
240
+ empathy = disposition[:empathy] || disposition['empathy']
241
+
242
+ missing = []
243
+ missing << 'skepticism' if skepticism.nil?
244
+ missing << 'literalism' if literalism.nil?
245
+ missing << 'empathy' if empathy.nil?
246
+ raise ValidationError, "Invalid #{key}: missing #{missing.join(', ')}" unless missing.empty?
247
+
248
+ {
249
+ skepticism: normalize_integer_in_range(skepticism, key: :skepticism, minimum: 1, maximum: 5),
250
+ literalism: normalize_integer_in_range(literalism, key: :literalism, minimum: 1, maximum: 5),
251
+ empathy: normalize_integer_in_range(empathy, key: :empathy, minimum: 1, maximum: 5)
252
+ }
253
+ end
254
+ end
255
+ end