ask-core 0.5.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 +4 -4
- data/CHANGELOG.md +58 -0
- data/lib/ask/content.rb +200 -0
- data/lib/ask/conversation.rb +114 -13
- data/lib/ask/document.rb +71 -0
- data/lib/ask/state.rb +69 -1
- data/lib/ask/version.rb +1 -1
- data/lib/ask.rb +4 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 07cf04e5db86d1aa2ed63aad10f47608619f320e00af7d730b0cd5b950f347ff
|
|
4
|
+
data.tar.gz: 99ef57dd70f631d3bfa024eb03b63279df7d251cab2afa68988cecd66140bc92
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 62ca489c64a4b6ef8e89603e9a4d0442d7b9a4c5c54e61cbb46dc3803046d1b120e2b5e061c6826ee0ad1b2fc8cec9618f6fe3ec5587970efdd99a29d46b89af
|
|
7
|
+
data.tar.gz: c228dc1e27f86a24eddc30b60e928dbdf2ca59ee45feb441954b07c91787ba55c825d3d537fb6cca7fba312908100d220ab63ab283d5b6736bb0fe60d1248ba3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
+
## [0.7.0] — 2026-07-26
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
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.
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
doc = Ask::Document.new(
|
|
45
|
+
content: "Ruby was created by Matz in 1995.",
|
|
46
|
+
metadata: { source: "history.pdf", page: 3 }
|
|
47
|
+
)
|
|
48
|
+
doc.content # => "Ruby was created by Matz in 1995."
|
|
49
|
+
doc.metadata # => { source: "history.pdf", page: 3 }
|
|
50
|
+
doc.to_h # => { content: "...", metadata: { source: "history.pdf", page: 3 } }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## [0.5.0] — 2026-07-22
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- `Ask::CostCalculator` — compute API costs from model pricing data.
|
|
58
|
+
|
|
1
59
|
## [0.4.0] — 2026-07-21
|
|
2
60
|
|
|
3
61
|
### Added
|
data/lib/ask/content.rb
ADDED
|
@@ -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
|
data/lib/ask/conversation.rb
CHANGED
|
@@ -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
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
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
|
-
|
|
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|
|
|
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)
|
|
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/document.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
# A piece of text content with associated metadata.
|
|
5
|
+
#
|
|
6
|
+
# Document is the universal value object for the RAG pipeline. It flows
|
|
7
|
+
# through every stage: loaded from files by {Ask::Loader::Base loaders},
|
|
8
|
+
# split into smaller pieces by {Ask::TextSplitter::Base splitters},
|
|
9
|
+
# embedded and stored by vector stores, and returned by retrieval queries.
|
|
10
|
+
#
|
|
11
|
+
# Immutable after creation. Two documents are equal when their content and
|
|
12
|
+
# metadata match.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# doc = Ask::Document.new(
|
|
16
|
+
# content: "Ruby was created by Matz in 1995.",
|
|
17
|
+
# metadata: { source: "history.pdf", page: 3 }
|
|
18
|
+
# )
|
|
19
|
+
# doc.content # => "Ruby was created by Matz in 1995."
|
|
20
|
+
# doc.metadata # => { source: "history.pdf", page: 3 }
|
|
21
|
+
# doc.id # => nil
|
|
22
|
+
#
|
|
23
|
+
class Document
|
|
24
|
+
# @return [String] the text content of this document
|
|
25
|
+
attr_reader :content
|
|
26
|
+
|
|
27
|
+
# @return [Hash] arbitrary metadata (source, page, chunk index, etc.)
|
|
28
|
+
attr_reader :metadata
|
|
29
|
+
|
|
30
|
+
# @return [String, nil] optional unique identifier
|
|
31
|
+
attr_reader :id
|
|
32
|
+
|
|
33
|
+
# @param content [String] the text content
|
|
34
|
+
# @param metadata [Hash] arbitrary metadata (default: {})
|
|
35
|
+
# @param id [String, nil] optional identifier
|
|
36
|
+
def initialize(content:, metadata: {}, id: nil)
|
|
37
|
+
@content = content.to_s
|
|
38
|
+
@metadata = metadata.dup.freeze
|
|
39
|
+
@id = id
|
|
40
|
+
freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Hash] serializable representation
|
|
44
|
+
def to_h
|
|
45
|
+
{ content: @content, metadata: @metadata, id: @id }.compact
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [String] JSON representation
|
|
49
|
+
def to_json(*args)
|
|
50
|
+
to_h.to_json(*args)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Two documents are equal if their content and metadata match.
|
|
54
|
+
def ==(other)
|
|
55
|
+
return false unless other.is_a?(Document)
|
|
56
|
+
|
|
57
|
+
@content == other.content && @metadata == other.metadata
|
|
58
|
+
end
|
|
59
|
+
alias eql? ==
|
|
60
|
+
|
|
61
|
+
def hash
|
|
62
|
+
[@content, @metadata].hash
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [String]
|
|
66
|
+
def inspect
|
|
67
|
+
preview = @content.length > 60 ? "#{@content[0, 60]}..." : @content
|
|
68
|
+
"#<Ask::Document content=#{preview.inspect}>"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/ask/state.rb
CHANGED
|
@@ -65,11 +65,27 @@ module Ask
|
|
|
65
65
|
raise NotImplementedError
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
# Remove all keys from the store.
|
|
68
|
+
# Remove all keys from the store (key-value only by default).
|
|
69
|
+
# Subclasses should override to also clear locks, queues, and lists.
|
|
69
70
|
def clear
|
|
70
71
|
raise NotImplementedError
|
|
71
72
|
end
|
|
72
73
|
|
|
74
|
+
# Check if a key exists and has not expired.
|
|
75
|
+
# @param key [String] the key
|
|
76
|
+
# @return [Boolean] true if the key exists and is not expired
|
|
77
|
+
def exists?(key)
|
|
78
|
+
!get(key).nil?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Return all non-expired keys, optionally filtered by a glob pattern.
|
|
82
|
+
# Glob syntax: * matches any sequence, ? matches any single character.
|
|
83
|
+
# @param pattern [String, nil] optional glob pattern (e.g., "session:*")
|
|
84
|
+
# @return [Array<String>] matching keys
|
|
85
|
+
def keys(pattern: nil)
|
|
86
|
+
raise NotImplementedError
|
|
87
|
+
end
|
|
88
|
+
|
|
73
89
|
# Atomically set a value only if the key does not already exist.
|
|
74
90
|
# @param key [String] the key
|
|
75
91
|
# @param value [Object] the value
|
|
@@ -152,6 +168,23 @@ module Ask
|
|
|
152
168
|
def close
|
|
153
169
|
# no-op by default
|
|
154
170
|
end
|
|
171
|
+
|
|
172
|
+
# -- Glob pattern helpers --
|
|
173
|
+
|
|
174
|
+
# Convert a glob pattern (*, ?) to a SQL LIKE pattern.
|
|
175
|
+
# @param pattern [String] glob pattern (e.g., "session:*")
|
|
176
|
+
# @return [String] LIKE pattern (e.g., "session:%")
|
|
177
|
+
def self.glob_to_like(pattern)
|
|
178
|
+
pattern.gsub("*", "%").gsub("?", "_")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Convert a glob pattern (*, ?) to a Regexp.
|
|
182
|
+
# @param pattern [String] glob pattern (e.g., "session:*")
|
|
183
|
+
# @return [Regexp] matching regex (e.g., /\Asession:.*\z/)
|
|
184
|
+
def self.glob_to_regex(pattern)
|
|
185
|
+
escaped = Regexp.escape(pattern)
|
|
186
|
+
Regexp.new("\\A#{escaped.gsub("\\*", ".*").gsub("\\?", ".")}\\z")
|
|
187
|
+
end
|
|
155
188
|
end
|
|
156
189
|
|
|
157
190
|
# In-process state backend backed by a Hash.
|
|
@@ -211,6 +244,33 @@ module Ask
|
|
|
211
244
|
def clear
|
|
212
245
|
@mutex.synchronize do
|
|
213
246
|
@data.clear
|
|
247
|
+
@locks.clear
|
|
248
|
+
@queues.clear
|
|
249
|
+
@lists.clear
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def exists?(key)
|
|
254
|
+
@mutex.synchronize do
|
|
255
|
+
expiry = @data[key]&.dig(:expires_at)
|
|
256
|
+
return false if expiry && Time.now >= expiry
|
|
257
|
+
@data.key?(key)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def keys(pattern: nil)
|
|
262
|
+
@mutex.synchronize do
|
|
263
|
+
# Filter out expired keys
|
|
264
|
+
active = @data.select do |_k, v|
|
|
265
|
+
expiry = v[:expires_at]
|
|
266
|
+
expiry.nil? || Time.now < expiry
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
keys = active.keys.map(&:to_s)
|
|
270
|
+
return keys unless pattern
|
|
271
|
+
|
|
272
|
+
regex = self.class.glob_to_regex(pattern)
|
|
273
|
+
keys.select { |k| k.match?(regex) }
|
|
214
274
|
end
|
|
215
275
|
end
|
|
216
276
|
|
|
@@ -316,6 +376,14 @@ module Ask
|
|
|
316
376
|
@lists.clear
|
|
317
377
|
end
|
|
318
378
|
end
|
|
379
|
+
|
|
380
|
+
private
|
|
381
|
+
|
|
382
|
+
# Convert a glob pattern (*, ?) to a Regexp.
|
|
383
|
+
# Delegates to the shared class method for consistency.
|
|
384
|
+
def glob_to_regex(pattern)
|
|
385
|
+
self.class.glob_to_regex(pattern)
|
|
386
|
+
end
|
|
319
387
|
end
|
|
320
388
|
end
|
|
321
389
|
end
|
data/lib/ask/version.rb
CHANGED
data/lib/ask.rb
CHANGED
|
@@ -6,8 +6,10 @@ 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.
|
|
12
|
+
# Ask::Document is a text+metadata value object for RAG pipelines.
|
|
11
13
|
# Ask::ToolDef is an immutable tool definition struct.
|
|
12
14
|
# Ask::Result standardizes tool execution return values.
|
|
13
15
|
# Ask::Error provides structured error types.
|
|
@@ -15,11 +17,13 @@ module Ask
|
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
require_relative "ask/errors"
|
|
20
|
+
require_relative "ask/content"
|
|
18
21
|
require_relative "ask/tool_def"
|
|
19
22
|
require_relative "ask/result"
|
|
20
23
|
require_relative "ask/stream"
|
|
21
24
|
require_relative "ask/conversation"
|
|
22
25
|
require_relative "ask/provider"
|
|
23
26
|
require_relative "ask/models"
|
|
27
|
+
require_relative "ask/document"
|
|
24
28
|
require_relative "ask/state"
|
|
25
29
|
require_relative "ask/provider_tool"
|
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.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -64,7 +64,9 @@ files:
|
|
|
64
64
|
- LICENSE
|
|
65
65
|
- README.md
|
|
66
66
|
- lib/ask.rb
|
|
67
|
+
- lib/ask/content.rb
|
|
67
68
|
- lib/ask/conversation.rb
|
|
69
|
+
- lib/ask/document.rb
|
|
68
70
|
- lib/ask/errors.rb
|
|
69
71
|
- lib/ask/models.rb
|
|
70
72
|
- lib/ask/provider.rb
|