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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require_relative 'payload'
5
+
6
+ module Hindsight
7
+ module Types
8
+ class Fact
9
+ attr_reader :text, :type, :entities, :occurred_at, :context, :metadata, :raw
10
+
11
+ def initialize(text:, type: nil, entities: [], occurred_at: nil, context: nil, metadata: {}, raw: nil)
12
+ @text = text.to_s
13
+ @type = type&.to_sym
14
+ @entities = normalize_entities(entities)
15
+ @occurred_at = coerce_time(occurred_at)
16
+ @context = context
17
+ @metadata = metadata || {}
18
+ @raw = raw || {}
19
+ end
20
+
21
+ def self.from_api(payload)
22
+ raw = payload || {}
23
+ return new(text: raw.to_s, raw: {}) unless raw.is_a?(Hash)
24
+
25
+ value = Payload.stringify_keys(raw)
26
+
27
+ new(
28
+ text: value['text'] || value['content'],
29
+ type: value['type'] || value['kind'],
30
+ entities: value['entities'] || [],
31
+ occurred_at: value['occurred_at'],
32
+ context: value['context'],
33
+ metadata: value['metadata'] || {},
34
+ raw: value
35
+ )
36
+ end
37
+
38
+ def inspect
39
+ truncated = text.length > 60 ? "#{text.slice(0, 60)}..." : text
40
+ "#<#{self.class} text=#{truncated.inspect} type=#{type.inspect} entities=#{entities.inspect}>"
41
+ end
42
+
43
+ def to_s
44
+ text
45
+ end
46
+
47
+ def to_h
48
+ {
49
+ text: text,
50
+ type: type,
51
+ entities: entities,
52
+ occurred_at: occurred_at,
53
+ context: context,
54
+ metadata: metadata
55
+ }
56
+ end
57
+
58
+ private
59
+
60
+ def coerce_time(value)
61
+ return value if value.is_a?(Time)
62
+ return nil if value.nil? || value.to_s.strip.empty?
63
+
64
+ Time.parse(value.to_s)
65
+ rescue ArgumentError
66
+ nil
67
+ end
68
+
69
+ def normalize_entities(value)
70
+ Array(value).map do |entity|
71
+ entity.is_a?(Hash) ? Payload.stringify_keys(entity) : entity.to_s
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'payload'
4
+ require_relative '../errors'
5
+
6
+ module Hindsight
7
+ module Types
8
+ class OperationReceipt
9
+ attr_reader :operation_ids, :raw, :bank
10
+
11
+ def initialize(operation_ids:, raw: nil, bank: nil)
12
+ @operation_ids = Array(operation_ids).map(&:to_s)
13
+ @raw = raw || {}
14
+ @bank = bank
15
+ end
16
+
17
+ def inspect
18
+ "#<#{self.class} operation_ids=#{operation_ids.inspect}>"
19
+ end
20
+
21
+ def to_s
22
+ operation_ids.join(', ')
23
+ end
24
+
25
+ def self.from_api(payload = nil, bank: nil)
26
+ body = Payload.stringify_keys(payload || {})
27
+ operation_ids = body['operation_ids'] || Array(body['operation_id']).compact
28
+ new(operation_ids: operation_ids, raw: body, bank: bank)
29
+ end
30
+
31
+ def submitted?
32
+ operation_ids.any?
33
+ end
34
+
35
+ def operation_id
36
+ operation_ids.first
37
+ end
38
+
39
+ def fetch(bank: nil)
40
+ resolved_bank = resolve_bank(bank)
41
+ operation_ids.map { |id| resolved_bank.operations.get(id) }
42
+ end
43
+
44
+ def wait(bank: nil, interval: 1.0, timeout: 120.0)
45
+ resolved_bank = resolve_bank(bank)
46
+ resolved_bank.operations.wait(operation_ids, interval: interval, timeout: timeout)
47
+ end
48
+
49
+ def to_h
50
+ { operation_ids: operation_ids }
51
+ end
52
+
53
+ private
54
+
55
+ def resolve_bank(bank)
56
+ resolved_bank = bank || @bank
57
+ return resolved_bank if resolved_bank
58
+
59
+ raise ValidationError, 'bank is required when receipt was not created from a bank-scoped operation'
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'payload'
4
+
5
+ module Hindsight
6
+ module Types
7
+ class OperationStatus
8
+ TERMINAL_STATES = %i[completed failed cancelled error].freeze
9
+ SUCCESS_STATES = %i[completed].freeze
10
+ FAILURE_STATES = %i[failed cancelled error].freeze
11
+
12
+ attr_reader :id, :status, :error, :result, :raw
13
+
14
+ def initialize(id:, status:, error: nil, result: nil, raw: nil)
15
+ @id = id.to_s
16
+ @status = status.to_sym
17
+ @error = error
18
+ @result = result
19
+ @raw = raw || {}
20
+ end
21
+
22
+ def terminal?
23
+ TERMINAL_STATES.include?(status)
24
+ end
25
+
26
+ def successful?
27
+ SUCCESS_STATES.include?(status)
28
+ end
29
+
30
+ def failed?
31
+ FAILURE_STATES.include?(status)
32
+ end
33
+
34
+ def inspect
35
+ "#<#{self.class} id=#{id.inspect} status=#{status.inspect}>"
36
+ end
37
+
38
+ def to_s
39
+ "#{id}: #{status}"
40
+ end
41
+
42
+ def to_h
43
+ { id: id, status: status, error: error, result: result }
44
+ end
45
+
46
+ def self.from_api(payload = nil, operation_id: nil)
47
+ body = Payload.stringify_keys(payload || {})
48
+ id = operation_id || body['operation_id'] || body['id']
49
+ status = body['status'] || 'unknown'
50
+ error = body['error']
51
+ result = body['result']
52
+
53
+ new(id: id, status: status, error: error, result: result, raw: body)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+
5
+ module Hindsight
6
+ module Types
7
+ module Payload
8
+ module_function
9
+
10
+ def stringify_keys(value)
11
+ h = value || {}
12
+ raise ValidationError, 'payload must be a Hash' unless h.is_a?(Hash)
13
+
14
+ deep_stringify(h)
15
+ end
16
+
17
+ def deep_stringify(value)
18
+ case value
19
+ when Hash
20
+ value.each_with_object({}) do |(key, item), result|
21
+ result[key.to_s] = deep_stringify(item)
22
+ end
23
+ when Array
24
+ value.map { |item| deep_stringify(item) }
25
+ else
26
+ value
27
+ end
28
+ end
29
+
30
+ private_class_method :deep_stringify
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'payload'
4
+ require_relative 'fact'
5
+
6
+ module Hindsight
7
+ module Types
8
+ class RecallResult
9
+ include Enumerable
10
+
11
+ attr_reader :facts, :token_count, :query, :budget, :raw
12
+
13
+ def initialize(facts:, token_count: nil, query: nil, budget: nil, raw: nil)
14
+ @facts = Array(facts)
15
+ @token_count = token_count
16
+ @query = query
17
+ @budget = budget&.to_sym
18
+ @raw = raw || {}
19
+ end
20
+
21
+ def inspect
22
+ parts = ["facts=#{facts.size}", ("token_count=#{token_count}" if token_count), "query=#{query.inspect}"]
23
+ "#<#{self.class} #{parts.compact.join(' ')}>"
24
+ end
25
+
26
+ def to_s
27
+ facts.map(&:to_s).join("\n")
28
+ end
29
+
30
+ def each(&block)
31
+ facts.each(&block)
32
+ end
33
+
34
+ def empty?
35
+ facts.empty?
36
+ end
37
+
38
+ def to_h
39
+ { facts: facts.map(&:to_h), token_count: token_count, query: query, budget: budget }
40
+ end
41
+
42
+ def self.empty(query: nil, budget: nil)
43
+ new(facts: [], token_count: 0, query: query, budget: budget, raw: {})
44
+ end
45
+
46
+ def self.from_api(payload = nil, query: nil, budget: nil)
47
+ body = Payload.stringify_keys(payload || {})
48
+ facts_payload = body['facts'] || body['memories'] || body['results'] || []
49
+ token_count = body['token_count'] || body.dig('usage', 'total_tokens')
50
+
51
+ facts = Array(facts_payload).map { |item| Fact.from_api(item) }
52
+
53
+ new(
54
+ facts: facts,
55
+ token_count: token_count,
56
+ query: query || body['query'],
57
+ budget: budget || body['budget'],
58
+ raw: body
59
+ )
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'payload'
4
+ require_relative 'fact'
5
+
6
+ module Hindsight
7
+ module Types
8
+ class Reflection
9
+ attr_reader :raw, :text, :data
10
+
11
+ def initialize(raw: nil, text: nil, based_on: [], data: nil)
12
+ @raw = raw || {}
13
+ @text = text
14
+ @raw_based_on = based_on
15
+ @data = data
16
+ end
17
+
18
+ def based_on
19
+ @based_on ||= Array(@raw_based_on).map { |fact| Fact.from_api(fact) }
20
+ end
21
+
22
+ def self.from_api(payload)
23
+ body = Payload.stringify_keys(payload || {})
24
+ text = body['text'] || body['answer'] || body['reflection']
25
+ based_on = body['based_on'] || body['facts'] || []
26
+ data = body['structured_output'] || body['data'] || body['json']
27
+
28
+ new(raw: body, text: text, based_on: based_on, data: data)
29
+ end
30
+
31
+ def inspect
32
+ truncated = text && text.length > 60 ? "#{text.slice(0, 60)}..." : text
33
+ "#<#{self.class} text=#{truncated.inspect} based_on=#{based_on.size}>"
34
+ end
35
+
36
+ def to_s
37
+ text.to_s
38
+ end
39
+
40
+ def to_h
41
+ { text: text, based_on: based_on.map(&:to_h), data: data }
42
+ end
43
+
44
+ def json
45
+ data
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "errors"
5
+
6
+ module Hindsight
7
+ module UploadNormalizer
8
+ CONTENT_TYPES = {
9
+ ".pdf" => "application/pdf",
10
+ ".png" => "image/png",
11
+ ".jpg" => "image/jpeg",
12
+ ".jpeg" => "image/jpeg",
13
+ ".gif" => "image/gif",
14
+ ".webp" => "image/webp",
15
+ ".mp3" => "audio/mpeg",
16
+ ".wav" => "audio/wav",
17
+ ".m4a" => "audio/mp4",
18
+ ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
19
+ ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
20
+ ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
21
+ }.freeze
22
+
23
+ module_function
24
+
25
+ # Normalize file entries into upload hashes suitable for multipart upload.
26
+ # When +allowed_paths+ is set, path-based entries are restricted to those
27
+ # directories (symlinks are resolved via File.realpath before checking).
28
+ def normalize_uploads(files, allowed_paths: nil)
29
+ entries = (files.is_a?(Array) ? files : [files]).flatten.compact
30
+ raise ValidationError, "files must contain at least one entry" if entries.empty?
31
+
32
+ closers = []
33
+ begin
34
+ uploads = entries.map do |entry|
35
+ normalize_upload(entry, closers, allowed_paths: allowed_paths)
36
+ end
37
+ [uploads, closers]
38
+ rescue StandardError
39
+ closers.each do |io|
40
+ io.close unless io.closed?
41
+ rescue StandardError
42
+ nil
43
+ end
44
+ raise
45
+ end
46
+ end
47
+
48
+ def normalize_upload(entry, closers, allowed_paths: nil)
49
+ case entry
50
+ when String, Pathname
51
+ ensure_allowed_paths_present!(allowed_paths)
52
+ build_upload_from_path(entry, closers, allowed_paths: allowed_paths)
53
+ when Hash
54
+ build_upload_from_hash(entry)
55
+ else
56
+ raise ValidationError,
57
+ "Invalid file entry: #{entry.inspect}. Expected path string, Pathname, or upload hash"
58
+ end
59
+ end
60
+
61
+ def build_upload_from_path(path_like, closers, allowed_paths: nil)
62
+ path = File.realpath(path_like.to_s)
63
+ validate_path_allowed!(path, path_like, allowed_paths) if allowed_paths
64
+ raise ValidationError, "Path for retain_files is not a regular file: #{path_like.inspect}" unless File.file?(path)
65
+ raise ValidationError, "File not readable for retain_files: #{path_like.inspect}" unless File.readable?(path)
66
+ filename = sanitize_multipart_value(File.basename(path))
67
+ raise ValidationError, "Invalid filename for retain_files: #{path_like.inspect}" if filename.empty?
68
+
69
+ io = File.open(path, "rb")
70
+ closers << io
71
+ {
72
+ io: io,
73
+ filename: filename,
74
+ content_type: content_type_for_filename(path)
75
+ }
76
+ rescue Errno::ENOENT
77
+ raise ValidationError, "File not found for retain_files: #{path_like.inspect}"
78
+ rescue Errno::EACCES
79
+ raise ValidationError, "File not readable for retain_files: #{path_like.inspect}"
80
+ end
81
+
82
+ def build_upload_from_hash(entry)
83
+ io = entry[:io] || entry["io"]
84
+ filename = entry[:filename] || entry["filename"]
85
+ content_type = entry[:content_type] || entry["content_type"] || content_type_for_filename(filename)
86
+
87
+ raise ValidationError, "Invalid upload hash: missing :io" unless io.respond_to?(:read)
88
+ raise ValidationError, "Invalid upload hash: missing :filename" if filename.to_s.strip.empty?
89
+
90
+ sanitized_filename = sanitize_multipart_value(filename)
91
+ raise ValidationError, "Invalid upload hash: empty :filename after sanitization" if sanitized_filename.empty?
92
+
93
+ sanitized_content_type = sanitize_multipart_value(content_type)
94
+ raise ValidationError, "Invalid upload hash: empty :content_type after sanitization" if sanitized_content_type.empty?
95
+
96
+ {
97
+ io: io,
98
+ filename: sanitized_filename,
99
+ content_type: sanitized_content_type
100
+ }
101
+ end
102
+
103
+ def ensure_allowed_paths_present!(allowed_paths)
104
+ return unless allowed_paths.nil?
105
+
106
+ raise ValidationError,
107
+ "Path uploads require allowed_paths to be set. " \
108
+ "Use upload hashes ({io:, filename:, content_type:}) when you intentionally need unrestricted IO."
109
+ end
110
+
111
+ def validate_path_allowed!(resolved, original, allowed_paths)
112
+ dirs = normalize_allowed_paths(allowed_paths)
113
+ return if dirs.any? { |dir| resolved.start_with?("#{dir}/") || resolved == dir }
114
+
115
+ raise ValidationError,
116
+ "Path #{original.inspect} resolves to #{resolved.inspect} which is outside allowed_paths"
117
+ end
118
+
119
+ def normalize_allowed_paths(allowed_paths)
120
+ Array(allowed_paths).map.with_index do |dir, index|
121
+ path = dir.to_s.strip
122
+ raise ValidationError, "allowed_paths[#{index}] must be a non-empty path" if path.empty?
123
+
124
+ begin
125
+ File.realpath(path)
126
+ rescue Errno::ENOENT
127
+ File.expand_path(path)
128
+ rescue ArgumentError, SystemCallError
129
+ raise ValidationError, "Invalid allowed_paths[#{index}]: #{dir.inspect}"
130
+ end
131
+ end
132
+ end
133
+
134
+ def sanitize_multipart_value(value)
135
+ value.to_s.gsub(/[\r\n\0"]/, "").strip
136
+ end
137
+
138
+ def content_type_for_filename(filename)
139
+ CONTENT_TYPES.fetch(File.extname(filename.to_s).downcase, "application/octet-stream")
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hindsight
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hindsight"
data/lib/hindsight.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'hindsight/version'
4
+ require_relative 'hindsight/errors'
5
+ require_relative 'hindsight/option_validation'
6
+ require_relative 'hindsight/client'
7
+ require_relative 'hindsight/resources/base'
8
+ require_relative 'hindsight/resources/banks'
9
+ require_relative 'hindsight/resources/memories'
10
+ require_relative 'hindsight/resources/mental_models'
11
+ require_relative 'hindsight/resources/directives'
12
+ require_relative 'hindsight/resources/chunks'
13
+ require_relative 'hindsight/resources/documents'
14
+ require_relative 'hindsight/resources/entities'
15
+ require_relative 'hindsight/resources/operations'
16
+ require_relative 'hindsight/resources/observations'
17
+ require_relative 'hindsight/resources/tags'
18
+ require_relative 'hindsight/resources/config'
19
+ require_relative 'hindsight/resources/graph'
20
+ require_relative 'hindsight/upload_normalizer'
21
+ require_relative 'hindsight/bank'
22
+ require_relative 'hindsight/types/payload'
23
+ require_relative 'hindsight/types/fact'
24
+ require_relative 'hindsight/types/recall_result'
25
+ require_relative 'hindsight/types/reflection'
26
+ require_relative 'hindsight/types/operation_receipt'
27
+ require_relative 'hindsight/types/operation_status'
28
+
29
+ module Hindsight
30
+ end
metadata ADDED
@@ -0,0 +1,141 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hindsight-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrey Samsonov
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday-multipart
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '3.13'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '3.13'
68
+ - !ruby/object:Gem::Dependency
69
+ name: webmock
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '3.0'
82
+ description: Framework-agnostic ruby client for Hindsight APIs.
83
+ executables: []
84
+ extensions: []
85
+ extra_rdoc_files: []
86
+ files:
87
+ - LICENSE.txt
88
+ - README.md
89
+ - hindsight-ruby.gemspec
90
+ - lib/hindsight-ruby.rb
91
+ - lib/hindsight.rb
92
+ - lib/hindsight/bank.rb
93
+ - lib/hindsight/client.rb
94
+ - lib/hindsight/errors.rb
95
+ - lib/hindsight/option_validation.rb
96
+ - lib/hindsight/resources/banks.rb
97
+ - lib/hindsight/resources/base.rb
98
+ - lib/hindsight/resources/chunks.rb
99
+ - lib/hindsight/resources/config.rb
100
+ - lib/hindsight/resources/directives.rb
101
+ - lib/hindsight/resources/documents.rb
102
+ - lib/hindsight/resources/entities.rb
103
+ - lib/hindsight/resources/graph.rb
104
+ - lib/hindsight/resources/memories.rb
105
+ - lib/hindsight/resources/mental_models.rb
106
+ - lib/hindsight/resources/observations.rb
107
+ - lib/hindsight/resources/operations.rb
108
+ - lib/hindsight/resources/tags.rb
109
+ - lib/hindsight/types/fact.rb
110
+ - lib/hindsight/types/operation_receipt.rb
111
+ - lib/hindsight/types/operation_status.rb
112
+ - lib/hindsight/types/payload.rb
113
+ - lib/hindsight/types/recall_result.rb
114
+ - lib/hindsight/types/reflection.rb
115
+ - lib/hindsight/upload_normalizer.rb
116
+ - lib/hindsight/version.rb
117
+ homepage: https://github.com/kryzhovnik/hindsight-ruby
118
+ licenses:
119
+ - MIT
120
+ metadata:
121
+ source_code_uri: https://github.com/kryzhovnik/hindsight-ruby
122
+ bug_tracker_uri: https://github.com/kryzhovnik/hindsight-ruby/issues
123
+ changelog_uri: https://github.com/kryzhovnik/hindsight-ruby/releases
124
+ rdoc_options: []
125
+ require_paths:
126
+ - lib
127
+ required_ruby_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '3.1'
132
+ required_rubygems_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ requirements: []
138
+ rubygems_version: 4.0.3
139
+ specification_version: 4
140
+ summary: Standalone Hindsight API client for Ruby
141
+ test_files: []