ask-core 0.6.0 → 0.7.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: e9500049544d02d1e7a13d939c7e1bf7a532d5d45682302e9666d0e6d63b4488
4
- data.tar.gz: 9f249f01b749bda7700131ce107bc0d312477849dd6006e6a3e2ca4b814e5b31
3
+ metadata.gz: 07cf04e5db86d1aa2ed63aad10f47608619f320e00af7d730b0cd5b950f347ff
4
+ data.tar.gz: 99ef57dd70f631d3bfa024eb03b63279df7d251cab2afa68988cecd66140bc92
5
5
  SHA512:
6
- metadata.gz: b5ac63d0cf689c3bc22fb4ad4bac215fbc9aa022b74e63b453a87ab7c7d7a5c781fff2fc2c9b694d6fb9e669c858ec0f81c13bf21fac50753654f8ebed2c3c58
7
- data.tar.gz: 23a06ce41d67eee03322691919623dfa888e301d4607c9812dbb85a2c46ace50b16d99aa9d1c1ba879104f84f74981bbc9cf8d5c985132331211c6bcbd00509a
6
+ metadata.gz: 62ca489c64a4b6ef8e89603e9a4d0442d7b9a4c5c54e61cbb46dc3803046d1b120e2b5e061c6826ee0ad1b2fc8cec9618f6fe3ec5587970efdd99a29d46b89af
7
+ data.tar.gz: c228dc1e27f86a24eddc30b60e928dbdf2ca59ee45feb441954b07c91787ba55c825d3d537fb6cca7fba312908100d220ab63ab283d5b6736bb0fe60d1248ba3
data/CHANGELOG.md CHANGED
@@ -1,8 +1,44 @@
1
- ## [0.6.0] — 2026-07-26
1
+ ## [0.7.0] — 2026-07-26
2
2
 
3
3
  ### Added
4
4
 
5
- - **`Ask::Document`** a new frozen value object representing text content with metadata. Used as the universal currency in RAG pipelines: loaded by document loaders, split by text splitters, embedded and retrieved by vector stores. Immutable, equality-checked by content+metadata, and JSON-serializable.
5
+ - **Multi-modal content types** `Ask::Content` module with `Text`, `Image`, `Audio`, `Video`, and `File` value objects. Each is frozen, comparable, and serializable via `#to_h`.
6
+
7
+ ```ruby
8
+ Ask::Content::Text.new("What's in this image?")
9
+ Ask::Content::Image.new(url: "https://example.com/photo.jpg", mime_type: "image/jpeg")
10
+ Ask::Content::Audio.new(url: "https://example.com/audio.mp3", mime_type: "audio/mpeg")
11
+ Ask::Content::Video.new(url: "https://example.com/video.mp4", mime_type: "video/mp4")
12
+ Ask::Content::File.new(data: "file content", mime_type: "text/plain", filename: "notes.txt")
13
+ ```
14
+
15
+ - **`Ask::Message#content_blocks`** — `Message` now accepts an Array of `Ask::Content` objects as `content:`. The `#content` accessor still returns the plain text for backward compatibility. `#content_blocks` returns the structured blocks, `#multimodal?` checks for non-text blocks.
16
+
17
+ ```ruby
18
+ msg = Ask::Message.new(role: :user, content: [
19
+ Ask::Content::Text.new("What's in this image?"),
20
+ Ask::Content::Image.new(url: "https://example.com/photo.jpg", mime_type: "image/jpeg")
21
+ ])
22
+ msg.multimodal? # => true
23
+ msg.content_blocks # => [Text("What's in this image?"), Image(...)]
24
+ msg.content # => "What's in this image?"
25
+ ```
26
+
27
+ - **`Ask::Conversation`** — `#user` and `#system` now accept arrays of content blocks directly. `#dup` correctly handles content blocks.
28
+
29
+ - **`Ask::Content::Block`** — shared base module for all content types. Content blocks respond to `#to_h` for serialization.
30
+
31
+ ### Changed
32
+
33
+ - `Ask::Message#to_h` now serializes content blocks as an array of typed hashes (`{ type: "text", text: "..." }`, `{ type: "image", url: "...", mime_type: "..." }`, etc.) when the message has content_blocks.
34
+ - `Ask::Message#inspect` updated to show `multimodal`, `rich`, or `text` label.
35
+
36
+ ### Tested
37
+
38
+ - 311 tests, 611 assertions, 0 failures
39
+ - 69 new tests for Content types, multi-modal messages, serialization, equality, and Conversation integration
40
+
41
+ ## [0.6.0] — 2026-07-26: loaded by document loaders, split by text splitters, embedded and retrieved by vector stores. Immutable, equality-checked by content+metadata, and JSON-serializable.
6
42
 
7
43
  ```ruby
8
44
  doc = Ask::Document.new(
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ # Content types for multi-modal messages.
5
+ #
6
+ # These are frozen value objects representing different kinds of content
7
+ # that can appear in an {Ask::Message}. A message's {Message#content_blocks
8
+ # content_blocks} may contain multiple content objects of different types,
9
+ # allowing text, images, audio, video, and files to be interleaved naturally.
10
+ #
11
+ # All content types are frozen value objects with structural equality.
12
+ # They carry the minimum fields needed for provider-agnostic use; each
13
+ # provider serializes them to its own wire format.
14
+ #
15
+ # @example Text
16
+ # Ask::Content::Text.new("What's in this image?")
17
+ #
18
+ # @example Image from URL
19
+ # Ask::Content::Image.new(url: "https://example.com/photo.jpg",
20
+ # mime_type: "image/jpeg")
21
+ #
22
+ # @example Multi-modal message
23
+ # msg = Ask::Message.new(role: :user, content: [
24
+ # Ask::Content::Text.new("What's in this image?"),
25
+ # Ask::Content::Image.new(url: "https://example.com/photo.jpg",
26
+ # mime_type: "image/jpeg")
27
+ # ])
28
+ #
29
+ module Content
30
+ # Base module for all content types.
31
+ module Block
32
+ def to_h
33
+ raise NotImplementedError
34
+ end
35
+
36
+ def inspect
37
+ "#<#{self.class.name}#{inspect_fields}>"
38
+ end
39
+
40
+ private
41
+
42
+ def inspect_fields
43
+ ""
44
+ end
45
+ end
46
+
47
+ # A text block within a multi-modal message.
48
+ class Text
49
+ include Block
50
+
51
+ # @return [String] the text content
52
+ attr_reader :text
53
+
54
+ # @param text [String] the text content
55
+ def initialize(text)
56
+ @text = text
57
+ freeze
58
+ end
59
+
60
+ def ==(other)
61
+ other.is_a?(Text) && @text == other.text
62
+ end
63
+ alias eql? ==
64
+
65
+ def hash
66
+ @text.hash
67
+ end
68
+
69
+ def to_h
70
+ { type: "text", text: @text }
71
+ end
72
+
73
+ private
74
+
75
+ def inspect_fields
76
+ " #{@text.inspect}"
77
+ end
78
+ end
79
+
80
+ # Base class for media content types (Image, Audio, Video).
81
+ # All have optional +url+, +base64+, +mime_type+, and +file_id+ fields.
82
+ class Media
83
+ include Block
84
+
85
+ # @return [String, nil] URL of the media
86
+ attr_reader :url
87
+
88
+ # @return [String, nil] Base64-encoded media data
89
+ attr_reader :base64
90
+
91
+ # @return [String, nil] MIME type
92
+ attr_reader :mime_type
93
+
94
+ # @return [String, nil] Provider-managed file ID
95
+ attr_reader :file_id
96
+
97
+ # @param url [String, nil] URL of the media
98
+ # @param base64 [String, nil] Base64-encoded data
99
+ # @param mime_type [String, nil] MIME type
100
+ # @param file_id [String, nil] Provider-managed file ID
101
+ def initialize(url: nil, base64: nil, mime_type: nil, file_id: nil)
102
+ @url = url
103
+ @base64 = base64
104
+ @mime_type = mime_type
105
+ @file_id = file_id
106
+ freeze
107
+ end
108
+
109
+ def ==(other)
110
+ other.is_a?(Media) && @url == other.url && @base64 == other.base64 &&
111
+ @mime_type == other.mime_type && @file_id == other.file_id
112
+ end
113
+ alias eql? ==
114
+
115
+ def hash
116
+ [@url, @base64, @mime_type, @file_id].hash
117
+ end
118
+
119
+ def to_h
120
+ h = { type: media_type }
121
+ h[:url] = @url if @url
122
+ h[:base64] = @base64 if @base64
123
+ h[:mime_type] = @mime_type if @mime_type
124
+ h[:file_id] = @file_id if @file_id
125
+ h
126
+ end
127
+
128
+ private
129
+
130
+ def media_type
131
+ raise NotImplementedError
132
+ end
133
+ end
134
+
135
+ # An image block within a multi-modal message.
136
+ class Image < Media
137
+ private
138
+
139
+ def media_type
140
+ "image"
141
+ end
142
+ end
143
+
144
+ # An audio block within a multi-modal message.
145
+ class Audio < Media
146
+ private
147
+
148
+ def media_type
149
+ "audio"
150
+ end
151
+ end
152
+
153
+ # A video block within a multi-modal message.
154
+ class Video < Media
155
+ private
156
+
157
+ def media_type
158
+ "video"
159
+ end
160
+ end
161
+
162
+ # An inline file block within a multi-modal message.
163
+ class File
164
+ include Block
165
+
166
+ # @return [String] the file content
167
+ attr_reader :data
168
+
169
+ # @return [String, nil] MIME type
170
+ attr_reader :mime_type
171
+
172
+ # @return [String, nil] original filename
173
+ attr_reader :filename
174
+
175
+ # @param data [String] the file content
176
+ # @param mime_type [String, nil] MIME type
177
+ # @param filename [String, nil] original filename
178
+ def initialize(data:, mime_type: nil, filename: nil)
179
+ @data = data
180
+ @mime_type = mime_type
181
+ @filename = filename
182
+ freeze
183
+ end
184
+
185
+ def ==(other)
186
+ other.is_a?(File) && @data == other.data &&
187
+ @mime_type == other.mime_type && @filename == other.filename
188
+ end
189
+ alias eql? ==
190
+
191
+ def hash
192
+ [@data, @mime_type, @filename].hash
193
+ end
194
+
195
+ def to_h
196
+ { type: "file", data: @data, mime_type: @mime_type, filename: @filename }
197
+ end
198
+ end
199
+ end
200
+ end
@@ -9,9 +9,16 @@ module Ask
9
9
  # @return [Symbol] message role (:system, :user, :assistant, :tool)
10
10
  attr_reader :role
11
11
 
12
- # @return [String, nil] message text content
12
+ # @return [String, nil] message text content. For multi-modal messages
13
+ # this is the concatenation of all text blocks. See {#content_blocks}
14
+ # for the full structured content.
13
15
  attr_reader :content
14
16
 
17
+ # @return [Array<Ask::Content::Text, Ask::Content::Image, ...>, nil]
18
+ # structured content blocks for multi-modal messages. +nil+ when the
19
+ # message was created with a plain string +content:+ (backward compatible).
20
+ attr_reader :content_blocks
21
+
15
22
  # @return [String, nil] optional participant name (for multi-agent scenarios)
16
23
  attr_reader :name
17
24
 
@@ -24,17 +31,45 @@ module Ask
24
31
  # @return [Hash] arbitrary metadata attached to this message
25
32
  attr_reader :metadata
26
33
 
34
+ # Create a new message.
35
+ #
36
+ # @param role [Symbol, String] message role
37
+ # @param content [String, Array<Ask::Content>, nil] message text or array
38
+ # of content blocks. When an Array is given, each element must be one of
39
+ # the {Ask::Content} types. +content+ will be set to the plain text
40
+ # representation; use {#content_blocks} to access the structured blocks.
41
+ # @param name [String, nil] participant name
42
+ # @param tool_call_id [String, nil] tool call ID this message responds to
43
+ # @param tool_calls [Array<Hash>, nil] tool call invocations
44
+ # @param metadata [Hash] arbitrary metadata
27
45
  def initialize(role:, content: nil, name: nil, tool_call_id: nil, tool_calls: nil, metadata: {})
28
46
  @role = normalize_role!(role)
29
- @content = content
30
47
  @name = normalize_name(name)
31
48
  @tool_call_id = tool_call_id
32
49
  @tool_calls = tool_calls
33
50
  @metadata = metadata.dup.freeze
51
+
52
+ if content.is_a?(Array)
53
+ validate_content_blocks!(content)
54
+ @content_blocks = content.map(&:freeze).freeze
55
+ @content = extract_text_from_blocks(@content_blocks)
56
+ else
57
+ @content = content
58
+ @content_blocks = nil
59
+ end
60
+
34
61
  validate!
35
62
  freeze
36
63
  end
37
64
 
65
+ # @return [Boolean] true if this message contains non-text content blocks
66
+ # (images, audio, video, or files).
67
+ def multimodal?
68
+ return false unless @content_blocks
69
+
70
+ @content_blocks.any? { |b| !b.is_a?(Content::Text) && b.is_a?(Content::Block) }
71
+ end
72
+
38
73
  # @return [Boolean] true if this message contains tool calls
39
74
  def tool_call? = @tool_calls&.any? == true
40
75
 
@@ -54,16 +89,24 @@ module Ask
54
89
  def tool? = @role == :tool
55
90
 
56
91
  # Convert to a hash suitable for provider wire format serialization.
57
- # Omits nil-valued keys. Tool calls are converted from internal Hash format
58
- # ({id => object with .id, .name, .arguments}) to the provider API Array format
59
- # ([{id:, type:, function: {name:, arguments:}}]).
92
+ # For multi-modal messages, +:content+ is an array of content block hashes.
93
+ # For plain text messages, +:content+ is a string (backward compatible).
94
+ #
95
+ # Each content block hash includes a +:type+ key for discrimination.
96
+ # Providers can use these to build their wire format.
97
+ #
60
98
  # @return [Hash]
61
99
  def to_h
62
100
  base = { role: @role }
63
- base[:content] = @content if @content
64
101
  base[:name] = @name if @name
65
102
  base[:tool_call_id] = @tool_call_id if @tool_call_id
66
103
 
104
+ if @content_blocks
105
+ base[:content] = @content_blocks.map(&method(:serialize_content_block))
106
+ else
107
+ base[:content] = @content if @content
108
+ end
109
+
67
110
  if @tool_calls
68
111
  base[:tool_calls] = @tool_calls.is_a?(Array) ? @tool_calls : @tool_calls.map do |id_val, tc|
69
112
  tc_id = if tc.respond_to?(:id)
@@ -98,27 +141,62 @@ module Ask
98
141
  base
99
142
  end
100
143
 
101
- # @return [Boolean] true if role, content, name, and tool metadata all match
144
+ # @return [Boolean] true if role, content/block, name, and tool metadata all match
102
145
  def ==(other)
103
146
  return false unless other.is_a?(Message)
104
147
 
105
148
  @role == other.role && @content == other.content &&
149
+ @content_blocks == other.content_blocks &&
106
150
  @name == other.name && @tool_call_id == other.tool_call_id &&
107
151
  @tool_calls == other.tool_calls
108
152
  end
109
153
  alias eql? ==
110
154
 
111
155
  def hash
112
- [@role, @content, @name, @tool_call_id, @tool_calls].hash
156
+ [@role, @content, @content_blocks, @name, @tool_call_id, @tool_calls].hash
113
157
  end
114
158
 
115
159
  # @return [String]
116
160
  def inspect
117
- "#<Ask::Message role=#{@role.inspect} content=#{@content && @content.length > 57 ? @content[0,57].inspect + "..." : @content.inspect}>"
161
+ label = multimodal? ? "multimodal" : (@content_blocks ? "rich" : "text")
162
+ preview = if @content_blocks
163
+ blk = @content_blocks.find { |b| b.is_a?(Content::Text) }
164
+ blk ? preview_text(blk.text) : label
165
+ elsif @content
166
+ @content.length > 57 ? "#{@content[0,57]}..." : @content
167
+ end
168
+ "#<Ask::Message role=#{@role.inspect} #{label} #{preview.inspect}>"
169
+ end
170
+
171
+ def preview_text(text)
172
+ text.length > 57 ? "#{text[0, 57]}..." : text
118
173
  end
119
174
 
120
175
  private
121
176
 
177
+ def serialize_content_block(block)
178
+ block.respond_to?(:to_h) ? block.to_h : { type: "unknown", data: block.to_s }
179
+ end
180
+
181
+ def validate_content_blocks!(blocks)
182
+ raise ArgumentError, "Content blocks must be an Array" unless blocks.is_a?(Array)
183
+ raise ArgumentError, "Content blocks cannot be empty" if blocks.empty?
184
+
185
+ blocks.each_with_index do |block, idx|
186
+ next if block.is_a?(Content::Block)
187
+
188
+ raise ArgumentError,
189
+ "Invalid content block at index #{idx}: " \
190
+ "expected an Ask::Content type (Text, Image, Audio, Video, or File), " \
191
+ "got #{block.class}"
192
+ end
193
+ end
194
+
195
+ def extract_text_from_blocks(blocks)
196
+ texts = blocks.select { |b| b.is_a?(Content::Text) }.map(&:text)
197
+ texts.empty? ? nil : texts.join("\n")
198
+ end
199
+
122
200
  def normalize_role!(role)
123
201
  sym = role.to_s.downcase.to_sym
124
202
  raise InvalidRole, "Invalid role: #{role.inspect}. Valid: #{VALID_ROLES.join(', ')}" unless VALID_ROLES.include?(sym)
@@ -164,15 +242,23 @@ module Ask
164
242
  alias add <<
165
243
 
166
244
  # Add a system message.
167
- # @param text [String] message content
245
+ # @param text [String, Array<Ask::Content>] message content or content blocks
168
246
  # @return [self]
169
247
  def system(text, **options)
170
248
  self << Message.new(role: :system, content: text, **options)
171
249
  end
172
250
 
173
251
  # Add a user message.
174
- # @param text [String] message content
252
+ # @param text [String, Array<Ask::Content>] message content or content blocks.
253
+ # Pass an Array of {Ask::Content} objects for multi-modal messages.
175
254
  # @return [self]
255
+ # @example Plain text
256
+ # conv.user("Hello")
257
+ # @example Multi-modal
258
+ # conv.user([
259
+ # Ask::Content::Text.new("What's in this image?"),
260
+ # Ask::Content::Image.new(url: "https://example.com/photo.jpg", mime_type: "image/jpeg")
261
+ # ])
176
262
  def user(text, **options)
177
263
  self << Message.new(role: :user, content: text, **options)
178
264
  end
@@ -260,7 +346,16 @@ module Ask
260
346
  # Deep copy of this conversation.
261
347
  # @return [Ask::Conversation]
262
348
  def dup
263
- Conversation.new(@messages.map { |m| Message.new(**m.to_h) })
349
+ Conversation.new(@messages.map { |m|
350
+ opts = { role: m.role, name: m.name, tool_call_id: m.tool_call_id,
351
+ tool_calls: m.tool_calls, metadata: m.metadata }
352
+ if m.content_blocks
353
+ opts[:content] = m.content_blocks.dup
354
+ else
355
+ opts[:content] = m.content
356
+ end
357
+ Message.new(**opts)
358
+ })
264
359
  end
265
360
 
266
361
  # @return [String]
@@ -271,7 +366,13 @@ module Ask
271
366
  private
272
367
 
273
368
  def build_message(attrs)
274
- attrs.is_a?(Hash) ? Message.new(**attrs) : Message.new(role: :user, content: attrs.to_s)
369
+ if attrs.is_a?(Hash)
370
+ Message.new(**attrs)
371
+ elsif attrs.is_a?(Array)
372
+ Message.new(role: :user, content: attrs)
373
+ else
374
+ Message.new(role: :user, content: attrs.to_s)
375
+ end
275
376
  end
276
377
  end
277
378
  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.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/ask.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "ask/version"
6
6
  #
7
7
  # Ask::Provider is the abstract base class for LLM providers.
8
8
  # Ask::Conversation is a message container with role normalization.
9
+ # Ask::Content provides multi-modal content types (Text, Image, Audio, Video, File).
9
10
  # Ask::Stream provides streaming primitives for incremental responses.
10
11
  # Ask::ModelCatalog resolves model names to provider metadata.
11
12
  # Ask::Document is a text+metadata value object for RAG pipelines.
@@ -16,6 +17,7 @@ module Ask
16
17
  end
17
18
 
18
19
  require_relative "ask/errors"
20
+ require_relative "ask/content"
19
21
  require_relative "ask/tool_def"
20
22
  require_relative "ask/result"
21
23
  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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -64,6 +64,7 @@ files:
64
64
  - LICENSE
65
65
  - README.md
66
66
  - lib/ask.rb
67
+ - lib/ask/content.rb
67
68
  - lib/ask/conversation.rb
68
69
  - lib/ask/document.rb
69
70
  - lib/ask/errors.rb