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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +325 -0
- data/hindsight-ruby.gemspec +37 -0
- data/lib/hindsight/bank.rb +204 -0
- data/lib/hindsight/client.rb +296 -0
- data/lib/hindsight/errors.rb +36 -0
- data/lib/hindsight/option_validation.rb +255 -0
- data/lib/hindsight/resources/banks.rb +129 -0
- data/lib/hindsight/resources/base.rb +118 -0
- data/lib/hindsight/resources/chunks.rb +14 -0
- data/lib/hindsight/resources/config.rb +21 -0
- data/lib/hindsight/resources/directives.rb +58 -0
- data/lib/hindsight/resources/documents.rb +30 -0
- data/lib/hindsight/resources/entities.rb +27 -0
- data/lib/hindsight/resources/graph.rb +21 -0
- data/lib/hindsight/resources/memories.rb +30 -0
- data/lib/hindsight/resources/mental_models.rb +65 -0
- data/lib/hindsight/resources/observations.rb +16 -0
- data/lib/hindsight/resources/operations.rb +92 -0
- data/lib/hindsight/resources/tags.rb +18 -0
- data/lib/hindsight/types/fact.rb +76 -0
- data/lib/hindsight/types/operation_receipt.rb +63 -0
- data/lib/hindsight/types/operation_status.rb +57 -0
- data/lib/hindsight/types/payload.rb +33 -0
- data/lib/hindsight/types/recall_result.rb +63 -0
- data/lib/hindsight/types/reflection.rb +49 -0
- data/lib/hindsight/upload_normalizer.rb +142 -0
- data/lib/hindsight/version.rb +5 -0
- data/lib/hindsight-ruby.rb +3 -0
- data/lib/hindsight.rb +30 -0
- metadata +141 -0
|
@@ -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
|