ask-core 0.10.0 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b2d67418e2412efacf4d0b5c35d1897826f0da1c0a52c159f7503e8b794d66f1
4
- data.tar.gz: 2b39ea638d11e5147058a1f35e3aa3c130650972082330e2dd30a752eeb68a36
3
+ metadata.gz: 3919e331460c87569013ea41f713269840945b36dca9def8d3e73476c4e9bff5
4
+ data.tar.gz: 3055307c6774fb7cfdb690dd1b5cc5f14dc925670d95310a302b3d361d372b94
5
5
  SHA512:
6
- metadata.gz: 8910e469a984ca9f2758dc816b6b13714b5985fad2dbb0910b2d2d0b5313ff04abfcffad06e3fcf2de4a53eb2d3e34fda1292150867bae39d476b5ad4a3551e4
7
- data.tar.gz: 6ed636a7ed4b15f423b5914708064e44ac286e6488b87857d271646e83fad113b8dec97a1a7082e95bbd598d1ee2c631a77bffa0d03595ebd7d10db9a965b2ab
6
+ metadata.gz: 51fc3c4838e5ddb98c829525fbf3840b37ddd5683f4821c58b2ec9f4d2c3a5af4d77a54a32a911550aecfac56e9847f4a5940b06b923b04eae118bf522115040
7
+ data.tar.gz: 4560aa7626cc24ca53292828dd1b27f0405d09cff66557533734a3475b6a97d2f8e097fd67b14de827d3b78ead92d151a322969b8c08b833087d0089a3366699
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Ask
6
+ # A file a user attached to a message.
7
+ #
8
+ # Sources (exactly one per attachment):
9
+ # path: local file path
10
+ # url: http(s) or data: URI
11
+ # data: raw bytes
12
+ # io: an IO/StringIO (read at render time)
13
+ # blob: a duck-typed object with +download+/+path+/+read+
14
+ # (e.g. an ActiveStorage blob)
15
+ # file_id: a provider-managed file reference (no bytes here)
16
+ #
17
+ # Delivery modes:
18
+ # :inline (default) — the bytes are sent to the model (via the
19
+ # provider's serializers) so it can read the file. Provider
20
+ # capability gates apply.
21
+ # :context — only a manifest line reaches the model:
22
+ # "[Attached file: name (mime, N bytes)]". The model knows the file
23
+ # exists but never receives its content. Provider-agnostic — right
24
+ # for agents that must not read uploaded files (e.g. a
25
+ # requirements-gathering assistant).
26
+ #
27
+ # @example Inline image from a local path
28
+ # Ask::Attachment.new(path: "receipt.png")
29
+ #
30
+ # @example Context-only (know it exists, don't read it)
31
+ # Ask::Attachment.new(path: "invoice.csv", delivery: :context)
32
+ #
33
+ # @example ActiveStorage blob
34
+ # Ask::Attachment.new(blob: work_request.source_files.first)
35
+ class Attachment
36
+ DELIVERY_MODES = %i[inline context].freeze
37
+
38
+ # @return [String, nil] original filename
39
+ attr_reader :filename
40
+
41
+ # @return [String] MIME type
42
+ attr_reader :mime_type
43
+
44
+ # @return [Integer, nil] size in bytes
45
+ attr_reader :size
46
+
47
+ # @return [Symbol] :inline or :context
48
+ attr_reader :delivery
49
+
50
+ # @return [String, nil] URL (http(s) or data: URI) when given
51
+ attr_reader :url
52
+
53
+ # @return [String, nil] provider-managed file ID when given
54
+ attr_reader :file_id
55
+
56
+ # @param path [String, nil] local file path
57
+ # @param url [String, nil] http(s) or data: URI
58
+ # @param data [String, nil] raw bytes
59
+ # @param io [IO, StringIO, nil]
60
+ # @param blob [Object, nil] duck-typed blob (+download+/+path+/+read+)
61
+ # @param file_id [String, nil] provider-managed file reference
62
+ # @param filename [String, nil] override the derived filename
63
+ # @param mime_type [String, nil] override the derived MIME type
64
+ # @param delivery [Symbol] :inline (default) or :context
65
+ def initialize(path: nil, url: nil, data: nil, io: nil, blob: nil, file_id: nil,
66
+ filename: nil, mime_type: nil, delivery: :inline)
67
+ sources = { path: path, url: url, data: data, io: io, blob: blob, file_id: file_id }
68
+ given = sources.count { |_, value| !value.nil? }
69
+ raise ArgumentError, "Provide exactly one attachment source" unless given == 1
70
+
71
+ @delivery = delivery.to_sym
72
+ raise ArgumentError, "Unknown delivery mode: #{delivery.inspect}" unless DELIVERY_MODES.include?(@delivery)
73
+
74
+ @path = path
75
+ @io = io
76
+ @blob = blob
77
+ @url = url
78
+ @file_id = file_id
79
+ @raw_data = data
80
+ @filename = filename || derive_filename
81
+ @mime_type = mime_type || derive_mime_type
82
+ @size = derive_size
83
+ freeze
84
+ end
85
+
86
+ # Raw bytes, when the source can provide them (path/io/data/blob/data
87
+ # URIs). nil for URLs and provider file references — those are passed
88
+ # to the provider as-is.
89
+ #
90
+ # @return [String, nil]
91
+ def data
92
+ return @raw_data unless @raw_data.nil?
93
+ return nil if @file_id
94
+ return decode_data_uri if data_uri?
95
+
96
+ if @path
97
+ File.binread(@path)
98
+ elsif @io
99
+ @io.rewind if @io.respond_to?(:rewind)
100
+ @io.read
101
+ elsif @blob
102
+ if @blob.respond_to?(:download)
103
+ @blob.download
104
+ elsif @blob.respond_to?(:read)
105
+ @blob.read
106
+ end
107
+ end
108
+ end
109
+
110
+ # Base64-encoded bytes, when available.
111
+ #
112
+ # @return [String, nil]
113
+ def base64
114
+ bytes = data
115
+ bytes && Base64.strict_encode64(bytes)
116
+ end
117
+
118
+ # Broad category of the file (see {Mime.classify}).
119
+ #
120
+ # @return [Symbol]
121
+ def type
122
+ Mime.classify(mime_type)
123
+ end
124
+
125
+ # The manifest line rendered for {delivery: :context} attachments.
126
+ #
127
+ # @return [String]
128
+ def manifest_line
129
+ details = [mime_type, (size ? "#{size} bytes" : nil)].compact.join(", ")
130
+ "[Attached file: #{filename || "file"} (#{details})]"
131
+ end
132
+
133
+ # Convert to the content block carried by the message.
134
+ #
135
+ # :context attachments become a plain {Content::Text} manifest line;
136
+ # :inline attachments become the matching media/file block so the
137
+ # provider serializers can send the bytes.
138
+ #
139
+ # @return [Content::Block]
140
+ def to_content
141
+ return Content::Text.new(manifest_line) if context?
142
+
143
+ case type
144
+ when :image
145
+ Content::Image.new(url: url, base64: base64, mime_type: mime_type, file_id: file_id)
146
+ when :audio
147
+ Content::Audio.new(url: url, base64: base64, mime_type: mime_type, file_id: file_id)
148
+ when :video
149
+ Content::Video.new(url: url, base64: base64, mime_type: mime_type, file_id: file_id)
150
+ else
151
+ Content::File.new(data: data, mime_type: mime_type, filename: filename,
152
+ url: url, file_id: file_id)
153
+ end
154
+ end
155
+
156
+ # @return [Boolean] whether only context (a manifest line) is sent
157
+ def context?
158
+ @delivery == :context
159
+ end
160
+
161
+ # @return [Boolean] whether the bytes are sent to the model
162
+ def inline?
163
+ @delivery == :inline
164
+ end
165
+
166
+ # Coerce a single value into an {Attachment}:
167
+ # Attachment → itself
168
+ # Content::Block → itself (already a block)
169
+ # String → treated as a local file path
170
+ # Hash → keyword constructor arguments
171
+ # duck-typed blob (+download+/+path+/+read+) → blob source
172
+ #
173
+ # @param value [Object]
174
+ # @return [Attachment, Content::Block]
175
+ def self.wrap(value)
176
+ case value
177
+ when Attachment, Content::Block
178
+ value
179
+ when String
180
+ new(path: value)
181
+ when Hash
182
+ new(**value.transform_keys(&:to_sym))
183
+ else
184
+ if value.respond_to?(:download) || value.respond_to?(:path) || value.respond_to?(:read)
185
+ new(blob: value)
186
+ else
187
+ raise ArgumentError, "Cannot use #{value.class} as an attachment"
188
+ end
189
+ end
190
+ end
191
+
192
+ # Coerce a value or array of values into an array of attachments
193
+ # (and/or content blocks).
194
+ #
195
+ # @param values [Object, Array<Object>]
196
+ # @return [Array<Attachment, Content::Block>]
197
+ def self.wrap_all(values)
198
+ Array(values).map { |value| wrap(value) }
199
+ end
200
+
201
+ private
202
+
203
+ attr_reader :path, :io, :blob
204
+
205
+ def derive_filename
206
+ case
207
+ when @path then File.basename(@path)
208
+ when @url
209
+ if data_uri?
210
+ nil
211
+ else
212
+ File.basename(URI.parse(@url).path)
213
+ end
214
+ when @io
215
+ @io.respond_to?(:path) ? File.basename(@io.path.to_s) : nil
216
+ when @blob
217
+ if @blob.respond_to?(:filename)
218
+ @blob.filename.to_s
219
+ elsif @blob.respond_to?(:name)
220
+ @blob.name.to_s
221
+ end
222
+ end
223
+ end
224
+
225
+ def derive_mime_type
226
+ bytes = sniffable_bytes
227
+ Mime.detect(filename: filename, bytes: bytes)
228
+ end
229
+
230
+ # Bytes we can afford to sniff without reading the whole file.
231
+ # Missing files resolve to nil (data/size raise at render time).
232
+ def sniffable_bytes
233
+ return @raw_data[0, 16] if @raw_data
234
+ return nil if @file_id || url && !data_uri?
235
+
236
+ if @path
237
+ File.binread(@path, 16) rescue nil
238
+ elsif @io
239
+ data&.to_s[0, 16]
240
+ elsif @blob
241
+ if @blob.respond_to?(:download)
242
+ @blob.download.to_s[0, 16]
243
+ elsif @blob.respond_to?(:read)
244
+ data&.to_s[0, 16]
245
+ end
246
+ end
247
+ end
248
+
249
+ def derive_size
250
+ return @raw_data.bytesize if @raw_data
251
+ return nil if @file_id
252
+ return decode_data_uri.bytesize if data_uri?
253
+
254
+ if @path
255
+ File.size(@path) rescue nil
256
+ elsif @blob && @blob.respond_to?(:byte_size)
257
+ @blob.byte_size
258
+ end
259
+ end
260
+
261
+ def data_uri?
262
+ @url.to_s.start_with?("data:")
263
+ end
264
+
265
+ def decode_data_uri
266
+ DataURI.decode(@url).last
267
+ end
268
+ end
269
+ end
data/lib/ask/content.rb CHANGED
@@ -159,11 +159,14 @@ module Ask
159
159
  end
160
160
  end
161
161
 
162
- # An inline file block within a multi-modal message.
162
+ # A file block within a multi-modal message.
163
+ #
164
+ # Carries inline +data+ by default; +url+ or +file_id+ can be used
165
+ # instead when the file lives somewhere the provider can reach.
163
166
  class File
164
167
  include Block
165
168
 
166
- # @return [String] the file content
169
+ # @return [String, nil] raw file content
167
170
  attr_reader :data
168
171
 
169
172
  # @return [String, nil] MIME type
@@ -172,29 +175,76 @@ module Ask
172
175
  # @return [String, nil] original filename
173
176
  attr_reader :filename
174
177
 
175
- # @param data [String] the file content
178
+ # @return [String, nil] URL of the file
179
+ attr_reader :url
180
+
181
+ # @return [String, nil] Provider-managed file ID
182
+ attr_reader :file_id
183
+
184
+ # @param data [String, nil] raw file content
176
185
  # @param mime_type [String, nil] MIME type
177
186
  # @param filename [String, nil] original filename
178
- def initialize(data:, mime_type: nil, filename: nil)
187
+ # @param url [String, nil] URL of the file
188
+ # @param file_id [String, nil] Provider-managed file ID
189
+ def initialize(data: nil, mime_type: nil, filename: nil, url: nil, file_id: nil)
179
190
  @data = data
180
191
  @mime_type = mime_type
181
192
  @filename = filename
193
+ @url = url
194
+ @file_id = file_id
182
195
  freeze
183
196
  end
184
197
 
185
198
  def ==(other)
186
- other.is_a?(File) && @data == other.data &&
187
- @mime_type == other.mime_type && @filename == other.filename
199
+ other.is_a?(File) && @data == other.data && @mime_type == other.mime_type &&
200
+ @filename == other.filename && @url == other.url && @file_id == other.file_id
188
201
  end
189
202
  alias eql? ==
190
203
 
191
204
  def hash
192
- [@data, @mime_type, @filename].hash
205
+ [@data, @mime_type, @filename, @url, @file_id].hash
193
206
  end
194
207
 
195
208
  def to_h
196
- { type: "file", data: @data, mime_type: @mime_type, filename: @filename }
209
+ h = { type: "file" }
210
+ h[:data] = @data if @data
211
+ h[:mime_type] = @mime_type if @mime_type
212
+ h[:filename] = @filename if @filename
213
+ h[:url] = @url if @url
214
+ h[:file_id] = @file_id if @file_id
215
+ h
216
+ end
217
+ end
218
+
219
+ # Rebuild a content block from a +to_h+ hash (persistence round-trips).
220
+ #
221
+ # @param hash [Hash] a hash produced by {Block#to_h}
222
+ # @return [Block]
223
+ def self.from_h(hash)
224
+ hash = hash.transform_keys(&:to_s)
225
+ case hash["type"]
226
+ when "text"
227
+ Text.new(hash["text"].to_s)
228
+ when "image"
229
+ Image.new(**media_args(hash))
230
+ when "audio"
231
+ Audio.new(**media_args(hash))
232
+ when "video"
233
+ Video.new(**media_args(hash))
234
+ when "file"
235
+ File.new(
236
+ data: hash["data"], mime_type: hash["mime_type"], filename: hash["filename"],
237
+ url: hash["url"], file_id: hash["file_id"]
238
+ )
239
+ else
240
+ raise ArgumentError, "Unknown content block type: #{hash["type"].inspect}"
197
241
  end
198
242
  end
243
+
244
+ # @api private
245
+ def self.media_args(hash)
246
+ { url: hash["url"], base64: hash["base64"], mime_type: hash["mime_type"], file_id: hash["file_id"] }
247
+ end
248
+ private_class_method :media_args
199
249
  end
200
250
  end
@@ -141,6 +141,26 @@ module Ask
141
141
  base
142
142
  end
143
143
 
144
+ # Rebuild a message from a +to_h+ hash (persistence round-trips).
145
+ # Content-block arrays are reconstructed via {Content.from_h}.
146
+ #
147
+ # @param hash [Hash] a hash produced by {#to_h}
148
+ # @return [Message]
149
+ def self.from_h(hash)
150
+ hash = hash.transform_keys(&:to_s)
151
+ content = hash["content"]
152
+ content = content.map { |block| Content.from_h(block) } if content.is_a?(Array)
153
+
154
+ new(
155
+ role: hash["role"].to_sym,
156
+ content: content,
157
+ name: hash["name"],
158
+ tool_call_id: hash["tool_call_id"],
159
+ tool_calls: hash["tool_calls"],
160
+ metadata: hash["metadata"] || {}
161
+ )
162
+ end
163
+
144
164
  # @return [Boolean] true if role, content/block, name, and tool metadata all match
145
165
  def ==(other)
146
166
  return false unless other.is_a?(Message)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "uri"
5
+
6
+ module Ask
7
+ # Build and parse data: URIs (RFC 2397).
8
+ #
9
+ # @example
10
+ # Ask::DataURI.encode("hello", mime_type: "text/plain")
11
+ # # => "data:text/plain;base64,aGVsbG8="
12
+ #
13
+ # Ask::DataURI.decode("data:text/plain;base64,aGVsbG8=")
14
+ # # => ["text/plain", "hello"]
15
+ module DataURI
16
+ module_function
17
+
18
+ # Encode raw bytes as a data URI.
19
+ #
20
+ # @param data [String] raw bytes
21
+ # @param mime_type [String] MIME type
22
+ # @return [String]
23
+ def encode(data, mime_type: "application/octet-stream")
24
+ from_base64(Base64.strict_encode64(data), mime_type: mime_type)
25
+ end
26
+
27
+ # Build a data URI from already-encoded base64 (e.g. content blocks
28
+ # that carry a +base64+ field).
29
+ #
30
+ # @param base64 [String] base64-encoded data
31
+ # @param mime_type [String] MIME type
32
+ # @return [String]
33
+ def from_base64(base64, mime_type: "application/octet-stream")
34
+ "data:#{mime_type};base64,#{base64}"
35
+ end
36
+
37
+ # Parse a data URI into [mime_type, raw bytes].
38
+ #
39
+ # @param uri [String] a data: URI
40
+ # @return [Array(String, String)]
41
+ # @raise [ArgumentError] when +uri+ is not a data URI
42
+ def decode(uri)
43
+ match = uri.to_s.match(%r{\Adata:([^;,]*)((?:;[^,]*)*),(.*)\z}m)
44
+ raise ArgumentError, "Not a data URI: #{uri.to_s[0, 60].inspect}..." unless match
45
+
46
+ mime = match[1].empty? ? "text/plain" : match[1]
47
+ params = match[2]
48
+ payload = match[3]
49
+ if params.include?(";base64")
50
+ [mime, Base64.strict_decode64(payload)]
51
+ else
52
+ [mime, URI.decode_www_form_component(payload)]
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/ask/mime.rb ADDED
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ # Lightweight, dependency-free MIME detection and classification.
5
+ #
6
+ # Sniffs magic bytes for common binary formats and maps extensions for
7
+ # text/documents; everything else falls back to
8
+ # +application/octet-stream+. Kept deliberately small — ask-core has no
9
+ # runtime dependencies.
10
+ module Mime
11
+ # Common binary signatures → MIME type. Each entry may carry an extra
12
+ # proc that must also match (e.g. RIFF…WEBP for webp).
13
+ MAGIC = [
14
+ ["\x89PNG\r\n\x1a\n", "image/png"],
15
+ ["\xFF\xD8\xFF", "image/jpeg"],
16
+ ["GIF87a", "image/gif"],
17
+ ["GIF89a", "image/gif"],
18
+ ["RIFF", "image/webp", ->(bytes) { bytes[8, 4] == "WEBP" }],
19
+ ["%PDF-", "application/pdf"],
20
+ ["OggS", "audio/ogg"]
21
+ ].freeze
22
+
23
+ # Extension → MIME type.
24
+ EXTENSIONS = {
25
+ "txt" => "text/plain",
26
+ "csv" => "text/csv",
27
+ "json" => "application/json",
28
+ "xml" => "application/xml",
29
+ "md" => "text/markdown",
30
+ "html" => "text/html",
31
+ "rb" => "text/x-ruby",
32
+ "py" => "text/x-python",
33
+ "js" => "text/javascript",
34
+ "ts" => "text/typescript",
35
+ "pdf" => "application/pdf",
36
+ "png" => "image/png",
37
+ "jpg" => "image/jpeg",
38
+ "jpeg" => "image/jpeg",
39
+ "gif" => "image/gif",
40
+ "webp" => "image/webp",
41
+ "mp3" => "audio/mpeg",
42
+ "wav" => "audio/wav",
43
+ "ogg" => "audio/ogg",
44
+ "mp4" => "video/mp4",
45
+ "mov" => "video/quicktime",
46
+ "webm" => "video/webm",
47
+ "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
48
+ "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
49
+ }.freeze
50
+
51
+ module_function
52
+
53
+ # Detect a MIME type from a filename and/or raw bytes.
54
+ #
55
+ # A known extension wins (docx/xlsx are zip containers and would
56
+ # otherwise sniff as application/zip); magic bytes are used when the
57
+ # extension is unknown.
58
+ #
59
+ # @param filename [String, nil]
60
+ # @param bytes [String, nil]
61
+ # @param default [String] fallback MIME type
62
+ # @return [String]
63
+ def detect(filename: nil, bytes: nil, default: "application/octet-stream")
64
+ from_extension = from_extension(filename) if filename
65
+ return from_extension if from_extension
66
+
67
+ sniff(bytes) || default
68
+ end
69
+
70
+ # Sniff magic bytes. Returns a MIME type or nil.
71
+ #
72
+ # @param bytes [String] raw bytes
73
+ # @return [String, nil]
74
+ def sniff(bytes)
75
+ return nil if bytes.nil? || bytes.empty?
76
+
77
+ MAGIC.each do |(signature, mime, extra)|
78
+ next unless bytes.start_with?(signature)
79
+ next if extra && !extra.call(bytes)
80
+
81
+ return mime
82
+ end
83
+ nil
84
+ end
85
+
86
+ # Map a filename's extension to a MIME type, or nil.
87
+ #
88
+ # @param filename [String, nil]
89
+ # @return [String, nil]
90
+ def from_extension(filename)
91
+ extension = filename.to_s.split(".").last&.downcase
92
+ EXTENSIONS[extension]
93
+ end
94
+
95
+ # Classify a MIME type into a broad category.
96
+ #
97
+ # @param mime_type [String, nil]
98
+ # @return [Symbol] one of :image, :audio, :video, :pdf, :document,
99
+ # :text, :unknown
100
+ def classify(mime_type)
101
+ case mime_type.to_s
102
+ when %r{\Aimage/} then :image
103
+ when %r{\Aaudio/} then :audio
104
+ when %r{\Avideo/} then :video
105
+ when "application/pdf" then :pdf
106
+ when %r{\Atext/}, "application/json", "application/xml",
107
+ "application/javascript",
108
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
109
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
110
+ "application/msword", "application/vnd.ms-excel"
111
+ then :document
112
+ else
113
+ :unknown
114
+ end
115
+ end
116
+ end
117
+ end
data/lib/ask/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ask
4
- VERSION = "0.10.0"
4
+ VERSION = "0.11.0"
5
5
  end
data/lib/ask.rb CHANGED
@@ -17,7 +17,10 @@ module Ask
17
17
  end
18
18
 
19
19
  require_relative "ask/errors"
20
+ require_relative "ask/mime"
21
+ require_relative "ask/data_uri"
20
22
  require_relative "ask/content"
23
+ require_relative "ask/attachment"
21
24
  require_relative "ask/tool_def"
22
25
  require_relative "ask/result"
23
26
  require_relative "ask/stream"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -64,10 +64,13 @@ files:
64
64
  - LICENSE
65
65
  - README.md
66
66
  - lib/ask.rb
67
+ - lib/ask/attachment.rb
67
68
  - lib/ask/content.rb
68
69
  - lib/ask/conversation.rb
70
+ - lib/ask/data_uri.rb
69
71
  - lib/ask/document.rb
70
72
  - lib/ask/errors.rb
73
+ - lib/ask/mime.rb
71
74
  - lib/ask/models.rb
72
75
  - lib/ask/provider.rb
73
76
  - lib/ask/provider_tool.rb