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,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Hindsight
6
+ module Resources
7
+ class Banks < Base
8
+ BANK_PAYLOAD_KEYS = %i[
9
+ name
10
+ mission
11
+ reflect_mission
12
+ disposition
13
+ disposition_skepticism
14
+ disposition_literalism
15
+ disposition_empathy
16
+ skepticism
17
+ literalism
18
+ empathy
19
+ enable_observations
20
+ observations_mission
21
+ retain_mission
22
+ retain_extraction_mode
23
+ retain_custom_instructions
24
+ retain_chunk_size
25
+ ].freeze
26
+
27
+ endpoint :list, method: :get, path: '' do |params|
28
+ {
29
+ query: {
30
+ **extract_pagination!(params)
31
+ }
32
+ }
33
+ end
34
+
35
+ def get(bank_id)
36
+ id = normalize_required_id(bank_id, key: :bank_id)
37
+ request_json(:get, '/%{bank_id}/profile', path_params: { bank_id: id })
38
+ end
39
+
40
+ def stats(bank_id)
41
+ id = normalize_required_id(bank_id, key: :bank_id)
42
+ request_json(:get, '/%{bank_id}/stats', path_params: { bank_id: id })
43
+ end
44
+
45
+ endpoint :create, method: :put, path: '/%{bank_id}' do |params|
46
+ bank_id = normalize_required_id(params.delete(:bank_id), key: :bank_id)
47
+ body = normalize_bank_payload(extract_bank_payload!(params))
48
+ { path_params: { bank_id: bank_id }, body: body }
49
+ end
50
+
51
+ endpoint :update, method: :patch, path: '/%{bank_id}' do |params|
52
+ bank_id = normalize_required_id(params.delete(:bank_id), key: :bank_id)
53
+ body = normalize_bank_payload(extract_bank_payload!(params))
54
+ { path_params: { bank_id: bank_id }, body: body }
55
+ end
56
+
57
+ def delete(bank_id)
58
+ id = normalize_required_id(bank_id, key: :bank_id)
59
+ request_json(:delete, '/%{bank_id}', path_params: { bank_id: id })
60
+ end
61
+
62
+ private
63
+
64
+ def extract_bank_payload!(params)
65
+ BANK_PAYLOAD_KEYS.each_with_object({}) do |key, attributes|
66
+ next unless params.key?(key)
67
+
68
+ attributes[key] = params.delete(key)
69
+ end
70
+ end
71
+
72
+ def normalize_bank_payload(attributes)
73
+ payload = Types::Payload.stringify_keys(attributes || {})
74
+ normalize_disposition!(payload)
75
+ normalize_disposition_traits!(payload)
76
+ normalize_observations_flag!(payload)
77
+ normalize_extraction_mode!(payload)
78
+ normalize_chunk_size!(payload)
79
+
80
+ payload.compact
81
+ end
82
+
83
+ def normalize_disposition!(payload)
84
+ return unless payload.key?('disposition')
85
+
86
+ payload['disposition'] = OptionValidation.normalize_disposition(payload['disposition'], key: :disposition)
87
+ end
88
+
89
+ def normalize_disposition_traits!(payload)
90
+ %w[skepticism literalism empathy].each do |trait|
91
+ api_key = "disposition_#{trait}"
92
+ if payload.key?(trait) && payload.key?(api_key)
93
+ raise ValidationError,
94
+ "Conflicting disposition keys: provide either #{trait} or #{api_key}, not both"
95
+ end
96
+ payload[api_key] = payload.delete(trait) if payload.key?(trait) && !payload.key?(api_key)
97
+ payload.delete(trait)
98
+ next unless payload.key?(api_key) && !payload[api_key].nil?
99
+
100
+ payload[api_key] = OptionValidation.normalize_integer_in_range(
101
+ payload[api_key], key: api_key.to_sym, minimum: 1, maximum: 5
102
+ )
103
+ end
104
+ end
105
+
106
+ def normalize_observations_flag!(payload)
107
+ return unless payload.key?('enable_observations')
108
+
109
+ payload['enable_observations'] =
110
+ OptionValidation.normalize_boolean(payload['enable_observations'], key: :enable_observations)
111
+ end
112
+
113
+ def normalize_extraction_mode!(payload)
114
+ return unless payload.key?('retain_extraction_mode') && !payload['retain_extraction_mode'].nil?
115
+
116
+ payload['retain_extraction_mode'] =
117
+ OptionValidation.normalize_extraction_mode(payload['retain_extraction_mode']).to_s
118
+ end
119
+
120
+ def normalize_chunk_size!(payload)
121
+ return unless payload.key?('retain_chunk_size') && !payload['retain_chunk_size'].nil?
122
+
123
+ payload['retain_chunk_size'] =
124
+ OptionValidation.normalize_positive_integer(payload['retain_chunk_size'], key: :retain_chunk_size)
125
+ end
126
+
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+ require_relative '../option_validation'
5
+
6
+ module Hindsight
7
+ module Resources
8
+ class Base
9
+ def self.endpoint(name, method:, path:, &builder)
10
+ path_keys = path.scan(/%\{([a-zA-Z0-9_]+)\}/).flatten.map(&:to_sym).uniq.freeze
11
+
12
+ define_method(name) do |*args, **kwargs|
13
+ params = normalize_endpoint_params(name, args, kwargs, path_keys)
14
+ specification = builder ? instance_exec(params, &builder) : {}
15
+ specification = {} if specification.nil?
16
+ unless specification.is_a?(Hash)
17
+ raise ValidationError, "Invalid endpoint definition for #{self.class}##{name}: expected Hash"
18
+ end
19
+
20
+ ensure_no_unknown_kwargs!(params)
21
+
22
+ request_json(
23
+ method,
24
+ path,
25
+ path_params: specification[:path_params] || {},
26
+ query: specification[:query] || {},
27
+ body: specification[:body]
28
+ )
29
+ end
30
+ end
31
+
32
+ attr_reader :client, :base_path
33
+
34
+ def initialize(client:, base_path:)
35
+ @client = client
36
+ @base_path = base_path
37
+ end
38
+
39
+ private
40
+
41
+ def request_json(method, path_template, path_params: nil, query: nil, body: nil)
42
+ path = interpolate_path(path_template, path_params || {})
43
+ normalized_query = normalize_query_hash(query || {})
44
+ client.request_json(method, "#{base_path}#{path}", body, query: normalized_query)
45
+ end
46
+
47
+ def interpolate_path(template, values)
48
+ template.gsub(/%\{([a-zA-Z0-9_]+)\}/) do
49
+ key = Regexp.last_match(1).to_sym
50
+ raise ValidationError, "Missing required path parameter: #{key}" unless values.key?(key)
51
+
52
+ client.escape(values.fetch(key))
53
+ end
54
+ end
55
+
56
+ def normalize_query_hash(params)
57
+ params.each_with_object({}) do |(key, value), normalized|
58
+ next if value.nil?
59
+
60
+ normalized[key.to_s] = normalize_query_value(value)
61
+ end
62
+ end
63
+
64
+ def normalize_query_value(value)
65
+ if value.is_a?(Array)
66
+ value.compact.map(&:to_s)
67
+ else
68
+ value.to_s
69
+ end
70
+ end
71
+
72
+ def ensure_no_unknown_kwargs!(kwargs)
73
+ return if kwargs.empty?
74
+
75
+ raise ValidationError, "Unknown keyword arguments: #{kwargs.keys.join(', ')}"
76
+ end
77
+
78
+ def normalize_endpoint_params(name, args, kwargs, path_keys)
79
+ raise ValidationError, "Invalid arguments for #{self.class}##{name}: expected at most one positional argument" if args.length > 1
80
+ return kwargs.dup if args.empty?
81
+ unless kwargs.empty?
82
+ raise ValidationError,
83
+ "Invalid arguments for #{self.class}##{name}: cannot mix positional and keyword arguments"
84
+ end
85
+
86
+ arg = args.first
87
+ if arg.is_a?(Hash)
88
+ return arg.each_with_object({}) do |(key, value), params|
89
+ params[key.to_sym] = value
90
+ end
91
+ end
92
+
93
+ return { path_keys.first => arg } if path_keys.length == 1
94
+
95
+ raise ValidationError, "Invalid arguments for #{self.class}##{name}: expected keyword arguments"
96
+ end
97
+
98
+ def normalize_required_id(value, key:)
99
+ OptionValidation.normalize_non_empty_string(value, key: key)
100
+ end
101
+
102
+ def normalize_fact_type_filter(value)
103
+ OptionValidation.normalize_fact_type(value, key: :type)&.to_s
104
+ end
105
+
106
+ def normalize_tags_match(value)
107
+ OptionValidation.normalize_tags_match(value, key: :tags_match)&.to_s
108
+ end
109
+
110
+ def extract_pagination!(params)
111
+ {
112
+ limit: OptionValidation.normalize_optional_positive_integer(params.delete(:limit), key: :limit),
113
+ offset: OptionValidation.normalize_optional_non_negative_integer(params.delete(:offset), key: :offset)
114
+ }
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Hindsight
6
+ module Resources
7
+ class Chunks < Base
8
+ def get(chunk_id)
9
+ id = normalize_required_id(chunk_id, key: :chunk_id)
10
+ request_json(:get, '/chunks/%{chunk_id}', path_params: { chunk_id: id })
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Hindsight
6
+ module Resources
7
+ class Config < Base
8
+ endpoint :get, method: :get, path: '/config'
9
+
10
+ endpoint :patch, method: :patch, path: '/config' do |params|
11
+ {
12
+ body: {
13
+ updates: OptionValidation.normalize_required_hash(params.delete(:updates), key: :updates)
14
+ }
15
+ }
16
+ end
17
+
18
+ endpoint :reset, method: :delete, path: '/config'
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Hindsight
6
+ module Resources
7
+ class Directives < Base
8
+ endpoint :list, method: :get, path: '/directives' do |params|
9
+ active_only = params.delete(:active_only)
10
+ {
11
+ query: {
12
+ tags: OptionValidation.normalize_tags(params.delete(:tags)),
13
+ tags_match: normalize_tags_match(params.delete(:tags_match)),
14
+ active_only: OptionValidation.normalize_optional_boolean(active_only, key: :active_only),
15
+ **extract_pagination!(params)
16
+ }
17
+ }
18
+ end
19
+
20
+ endpoint :create, method: :post, path: '/directives' do |params|
21
+ active = params.delete(:is_active)
22
+ {
23
+ body: {
24
+ name: OptionValidation.normalize_non_empty_string(params.delete(:name), key: :name),
25
+ content: OptionValidation.normalize_non_empty_string(params.delete(:content), key: :content),
26
+ priority: OptionValidation.normalize_optional_integer(params.delete(:priority), key: :priority),
27
+ is_active: OptionValidation.normalize_optional_boolean(active, key: :is_active),
28
+ tags: OptionValidation.normalize_tags(params.delete(:tags))
29
+ }.compact
30
+ }
31
+ end
32
+
33
+ def get(directive_id)
34
+ id = normalize_required_id(directive_id, key: :directive_id)
35
+ request_json(:get, '/directives/%{directive_id}', path_params: { directive_id: id })
36
+ end
37
+
38
+ endpoint :update, method: :patch, path: '/directives/%{directive_id}' do |params|
39
+ active = params.delete(:is_active)
40
+ {
41
+ path_params: { directive_id: normalize_required_id(params.delete(:directive_id), key: :directive_id) },
42
+ body: {
43
+ name: OptionValidation.normalize_optional_non_empty_string(params.delete(:name), key: :name),
44
+ content: OptionValidation.normalize_optional_non_empty_string(params.delete(:content), key: :content),
45
+ priority: OptionValidation.normalize_optional_integer(params.delete(:priority), key: :priority),
46
+ is_active: OptionValidation.normalize_optional_boolean(active, key: :is_active),
47
+ tags: OptionValidation.normalize_tags(params.delete(:tags))
48
+ }.compact
49
+ }
50
+ end
51
+
52
+ def delete(directive_id)
53
+ id = normalize_required_id(directive_id, key: :directive_id)
54
+ request_json(:delete, '/directives/%{directive_id}', path_params: { directive_id: id })
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Hindsight
6
+ module Resources
7
+ class Documents < Base
8
+ endpoint :list, method: :get, path: '/documents' do |params|
9
+ {
10
+ query: {
11
+ q: OptionValidation.normalize_optional_non_empty_string(params.delete(:q), key: :q),
12
+ tags: OptionValidation.normalize_tags(params.delete(:tags)),
13
+ tags_match: normalize_tags_match(params.delete(:tags_match)),
14
+ **extract_pagination!(params)
15
+ }
16
+ }
17
+ end
18
+
19
+ def get(document_id)
20
+ id = normalize_required_id(document_id, key: :document_id)
21
+ request_json(:get, '/documents/%{document_id}', path_params: { document_id: id })
22
+ end
23
+
24
+ def delete(document_id)
25
+ id = normalize_required_id(document_id, key: :document_id)
26
+ request_json(:delete, '/documents/%{document_id}', path_params: { document_id: id })
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Hindsight
6
+ module Resources
7
+ class Entities < Base
8
+ endpoint :list, method: :get, path: '/entities' do |params|
9
+ {
10
+ query: {
11
+ **extract_pagination!(params)
12
+ }
13
+ }
14
+ end
15
+
16
+ def get(entity_id)
17
+ id = normalize_required_id(entity_id, key: :entity_id)
18
+ request_json(:get, '/entities/%{entity_id}', path_params: { entity_id: id })
19
+ end
20
+
21
+ def regenerate(entity_id)
22
+ id = normalize_required_id(entity_id, key: :entity_id)
23
+ request_json(:post, '/entities/%{entity_id}/regenerate', path_params: { entity_id: id })
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Hindsight
6
+ module Resources
7
+ class Graph < Base
8
+ endpoint :get, method: :get, path: '/graph' do |params|
9
+ {
10
+ query: {
11
+ type: OptionValidation.normalize_optional_non_empty_string(params.delete(:type), key: :type),
12
+ q: OptionValidation.normalize_optional_non_empty_string(params.delete(:q), key: :q),
13
+ tags: OptionValidation.normalize_tags(params.delete(:tags)),
14
+ tags_match: normalize_tags_match(params.delete(:tags_match)),
15
+ limit: OptionValidation.normalize_optional_positive_integer(params.delete(:limit), key: :limit)
16
+ }
17
+ }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Hindsight
6
+ module Resources
7
+ class Memories < Base
8
+ endpoint :list, method: :get, path: '/memories/list' do |params|
9
+ {
10
+ query: {
11
+ type: normalize_fact_type_filter(params.delete(:type)),
12
+ q: OptionValidation.normalize_optional_non_empty_string(params.delete(:q), key: :q),
13
+ **extract_pagination!(params)
14
+ }
15
+ }
16
+ end
17
+
18
+ def get(memory_id)
19
+ id = normalize_required_id(memory_id, key: :memory_id)
20
+ request_json(:get, '/memories/%{memory_id}', path_params: { memory_id: id })
21
+ end
22
+
23
+ endpoint :delete, method: :delete, path: '/memories' do |params|
24
+ {
25
+ query: { type: normalize_fact_type_filter(params.delete(:type)) }
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Hindsight
6
+ module Resources
7
+ class MentalModels < Base
8
+ endpoint :list, method: :get, path: '/mental-models' do |params|
9
+ {
10
+ query: {
11
+ tags: OptionValidation.normalize_tags(params.delete(:tags)),
12
+ tags_match: normalize_tags_match(params.delete(:tags_match)),
13
+ **extract_pagination!(params)
14
+ }
15
+ }
16
+ end
17
+
18
+ endpoint :create, method: :post, path: '/mental-models' do |params|
19
+ {
20
+ body: {
21
+ id: OptionValidation.normalize_optional_non_empty_string(params.delete(:id), key: :id),
22
+ name: OptionValidation.normalize_non_empty_string(params.delete(:name), key: :name),
23
+ source_query: OptionValidation.normalize_non_empty_string(params.delete(:source_query), key: :source_query),
24
+ tags: OptionValidation.normalize_tags(params.delete(:tags)),
25
+ max_tokens: OptionValidation.normalize_optional_positive_integer(params.delete(:max_tokens),
26
+ key: :max_tokens),
27
+ trigger: OptionValidation.normalize_hash(params.delete(:trigger), key: :trigger)
28
+ }.compact
29
+ }
30
+ end
31
+
32
+ def get(mental_model_id)
33
+ id = normalize_required_id(mental_model_id, key: :mental_model_id)
34
+ request_json(:get, '/mental-models/%{mental_model_id}', path_params: { mental_model_id: id })
35
+ end
36
+
37
+ endpoint :update, method: :patch, path: '/mental-models/%{mental_model_id}' do |params|
38
+ {
39
+ path_params: {
40
+ mental_model_id: normalize_required_id(params.delete(:mental_model_id), key: :mental_model_id)
41
+ },
42
+ body: {
43
+ name: OptionValidation.normalize_optional_non_empty_string(params.delete(:name), key: :name),
44
+ source_query: OptionValidation.normalize_optional_non_empty_string(params.delete(:source_query),
45
+ key: :source_query),
46
+ tags: OptionValidation.normalize_tags(params.delete(:tags)),
47
+ max_tokens: OptionValidation.normalize_optional_positive_integer(params.delete(:max_tokens),
48
+ key: :max_tokens),
49
+ trigger: OptionValidation.normalize_hash(params.delete(:trigger), key: :trigger)
50
+ }.compact
51
+ }
52
+ end
53
+
54
+ def delete(mental_model_id)
55
+ id = normalize_required_id(mental_model_id, key: :mental_model_id)
56
+ request_json(:delete, '/mental-models/%{mental_model_id}', path_params: { mental_model_id: id })
57
+ end
58
+
59
+ def refresh(mental_model_id)
60
+ id = normalize_required_id(mental_model_id, key: :mental_model_id)
61
+ request_json(:post, '/mental-models/%{mental_model_id}/refresh', path_params: { mental_model_id: id })
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Hindsight
6
+ module Resources
7
+ class Observations < Base
8
+ endpoint :delete, method: :delete, path: '/observations'
9
+
10
+ def delete_for_memory(memory_id)
11
+ id = normalize_required_id(memory_id, key: :memory_id)
12
+ request_json(:delete, '/memories/%{memory_id}/observations', path_params: { memory_id: id })
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../types/operation_status'
5
+
6
+ module Hindsight
7
+ module Resources
8
+ class Operations < Base
9
+ VALID_BACKOFF_MODES = %i[fixed exponential].freeze
10
+
11
+ endpoint :list, method: :get, path: '/operations' do |params|
12
+ {
13
+ query: {
14
+ status: OptionValidation.normalize_optional_non_empty_string(params.delete(:status), key: :status),
15
+ **extract_pagination!(params)
16
+ }
17
+ }
18
+ end
19
+
20
+ def get(operation_id)
21
+ id = normalize_required_id(operation_id, key: :operation_id)
22
+ body = request_json(:get, '/operations/%{operation_id}', path_params: { operation_id: id })
23
+ Types::OperationStatus.from_api(body, operation_id: id)
24
+ end
25
+
26
+ def wait(operation_ids, interval: 1.0, timeout: 120.0, backoff: :exponential, max_interval: 30.0)
27
+ ids = normalize_operation_ids(operation_ids)
28
+ interval_seconds = OptionValidation.normalize_positive_number(interval, key: :interval)
29
+ timeout_seconds = OptionValidation.normalize_positive_number(timeout, key: :timeout)
30
+ max_interval_seconds = OptionValidation.normalize_positive_number(max_interval, key: :max_interval)
31
+ backoff_mode = OptionValidation.normalize_enum(backoff, key: :backoff, valid: VALID_BACKOFF_MODES)
32
+ deadline = monotonic_now + timeout_seconds
33
+
34
+ completed = {}
35
+ pending_ids = ids.dup
36
+ attempts = 0
37
+
38
+ loop do
39
+ cycle_start = monotonic_now
40
+ statuses = pending_ids.map { |id| get(id) }
41
+ statuses.each do |status|
42
+ completed[status.id] = status if status.terminal?
43
+ end
44
+ pending_ids = pending_ids.reject { |id| completed.key?(id) }
45
+ return ids.map { |id| completed[id] } if pending_ids.empty?
46
+
47
+ now = monotonic_now
48
+ raise TimeoutError, "Timed out waiting for operations: #{pending_ids.join(', ')}" if now >= deadline
49
+
50
+ elapsed = now - cycle_start
51
+ cycle_interval = next_interval_seconds(
52
+ attempts: attempts,
53
+ interval_seconds: interval_seconds,
54
+ max_interval_seconds: max_interval_seconds,
55
+ backoff_mode: backoff_mode
56
+ )
57
+ remaining_interval = cycle_interval - elapsed
58
+ remaining_timeout = deadline - now
59
+ sleep_seconds = [remaining_interval, remaining_timeout].min
60
+ sleep(sleep_seconds) if sleep_seconds.positive?
61
+ attempts += 1
62
+ end
63
+ end
64
+
65
+ def cancel(operation_id)
66
+ id = normalize_required_id(operation_id, key: :operation_id)
67
+ request_json(:delete, '/operations/%{operation_id}', path_params: { operation_id: id })
68
+ end
69
+
70
+ private
71
+
72
+ def normalize_operation_ids(operation_ids)
73
+ ids = Array(operation_ids).flatten.compact.map do |operation_id|
74
+ normalize_required_id(operation_id, key: :operation_id)
75
+ end
76
+ return ids unless ids.empty?
77
+
78
+ raise ValidationError, 'operation_ids must contain at least one entry'
79
+ end
80
+
81
+ def monotonic_now
82
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
83
+ end
84
+
85
+ def next_interval_seconds(attempts:, interval_seconds:, max_interval_seconds:, backoff_mode:)
86
+ return interval_seconds if backoff_mode == :fixed
87
+
88
+ [interval_seconds * (2**attempts), max_interval_seconds].min
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Hindsight
6
+ module Resources
7
+ class Tags < Base
8
+ endpoint :list, method: :get, path: '/tags' do |params|
9
+ {
10
+ query: {
11
+ q: OptionValidation.normalize_optional_non_empty_string(params.delete(:q), key: :q),
12
+ **extract_pagination!(params)
13
+ }
14
+ }
15
+ end
16
+ end
17
+ end
18
+ end