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 +4 -4
- data/lib/ask/attachment.rb +269 -0
- data/lib/ask/content.rb +58 -8
- data/lib/ask/conversation.rb +20 -0
- data/lib/ask/data_uri.rb +56 -0
- data/lib/ask/mime.rb +117 -0
- data/lib/ask/version.rb +1 -1
- data/lib/ask.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3919e331460c87569013ea41f713269840945b36dca9def8d3e73476c4e9bff5
|
|
4
|
+
data.tar.gz: 3055307c6774fb7cfdb690dd1b5cc5f14dc925670d95310a302b3d361d372b94
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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]
|
|
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
|
-
# @
|
|
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
|
-
|
|
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
|
-
@
|
|
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"
|
|
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
|
data/lib/ask/conversation.rb
CHANGED
|
@@ -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)
|
data/lib/ask/data_uri.rb
ADDED
|
@@ -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
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.
|
|
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
|