ruby_llm 1.10.0 → 1.12.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/README.md +14 -2
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +41 -7
- data/lib/ruby_llm/active_record/chat_methods.rb +41 -7
- data/lib/ruby_llm/agent.rb +323 -0
- data/lib/ruby_llm/aliases.json +50 -32
- data/lib/ruby_llm/chat.rb +27 -3
- data/lib/ruby_llm/configuration.rb +4 -0
- data/lib/ruby_llm/models.json +19806 -5991
- data/lib/ruby_llm/models.rb +35 -6
- data/lib/ruby_llm/provider.rb +13 -1
- data/lib/ruby_llm/providers/anthropic/media.rb +2 -2
- data/lib/ruby_llm/providers/azure/chat.rb +29 -0
- data/lib/ruby_llm/providers/azure/embeddings.rb +24 -0
- data/lib/ruby_llm/providers/azure/media.rb +45 -0
- data/lib/ruby_llm/providers/azure/models.rb +14 -0
- data/lib/ruby_llm/providers/azure.rb +56 -0
- data/lib/ruby_llm/providers/bedrock/auth.rb +122 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +297 -56
- data/lib/ruby_llm/providers/bedrock/media.rb +62 -33
- data/lib/ruby_llm/providers/bedrock/models.rb +88 -65
- data/lib/ruby_llm/providers/bedrock/streaming.rb +305 -8
- data/lib/ruby_llm/providers/bedrock.rb +61 -52
- data/lib/ruby_llm/providers/openai/media.rb +1 -1
- data/lib/ruby_llm/providers/xai/chat.rb +15 -0
- data/lib/ruby_llm/providers/xai/models.rb +75 -0
- data/lib/ruby_llm/providers/xai.rb +28 -0
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm.rb +14 -8
- data/lib/tasks/models.rake +10 -4
- data/lib/tasks/vcr.rake +32 -0
- metadata +16 -13
- data/lib/ruby_llm/providers/bedrock/capabilities.rb +0 -167
- data/lib/ruby_llm/providers/bedrock/signing.rb +0 -831
- data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -51
- data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -128
- data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -67
- data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -85
- data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +0 -78
|
@@ -3,101 +3,342 @@
|
|
|
3
3
|
module RubyLLM
|
|
4
4
|
module Providers
|
|
5
5
|
class Bedrock
|
|
6
|
-
# Chat methods for
|
|
6
|
+
# Chat methods for Bedrock Converse API.
|
|
7
7
|
module Chat
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
-
def
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
def completion_url
|
|
11
|
+
"/model/#{@model.id}/converse"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument
|
|
15
|
+
@model = model
|
|
16
|
+
@used_document_names = {}
|
|
17
|
+
system_messages, chat_messages = messages.partition { |msg| msg.role == :system }
|
|
18
|
+
|
|
19
|
+
payload = {
|
|
20
|
+
messages: render_messages(chat_messages)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
system_blocks = render_system(system_messages)
|
|
24
|
+
payload[:system] = system_blocks unless system_blocks.empty?
|
|
25
|
+
|
|
26
|
+
payload[:inferenceConfig] = render_inference_config(model, temperature)
|
|
27
|
+
|
|
28
|
+
tool_config = render_tool_config(tools)
|
|
29
|
+
if tool_config
|
|
30
|
+
payload[:toolConfig] = tool_config
|
|
31
|
+
payload[:tools] = tool_config[:tools] # Internal mirror for shared payload inspections in specs.
|
|
15
32
|
end
|
|
16
|
-
|
|
33
|
+
|
|
34
|
+
additional_fields = render_additional_model_request_fields(thinking)
|
|
35
|
+
payload[:additionalModelRequestFields] = additional_fields if additional_fields
|
|
36
|
+
|
|
37
|
+
payload
|
|
17
38
|
end
|
|
18
39
|
|
|
19
|
-
def
|
|
20
|
-
|
|
40
|
+
def parse_completion_response(response)
|
|
41
|
+
data = response.body
|
|
42
|
+
return if data.nil? || data.empty?
|
|
21
43
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
content_blocks = data.dig('output', 'message', 'content') || []
|
|
45
|
+
usage = data['usage'] || {}
|
|
46
|
+
thinking_text, thinking_signature = parse_thinking(content_blocks)
|
|
47
|
+
|
|
48
|
+
Message.new(
|
|
49
|
+
role: :assistant,
|
|
50
|
+
content: parse_text_content(content_blocks),
|
|
51
|
+
thinking: Thinking.build(text: thinking_text, signature: thinking_signature),
|
|
52
|
+
tool_calls: parse_tool_calls(content_blocks),
|
|
53
|
+
input_tokens: usage['inputTokens'],
|
|
54
|
+
output_tokens: usage['outputTokens'],
|
|
55
|
+
cached_tokens: usage['cacheReadInputTokens'],
|
|
56
|
+
cache_creation_tokens: usage['cacheWriteInputTokens'],
|
|
57
|
+
thinking_tokens: usage['reasoningTokens'],
|
|
58
|
+
model_id: data['modelId'],
|
|
59
|
+
raw: response
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def render_messages(messages)
|
|
64
|
+
rendered = []
|
|
65
|
+
tool_result_blocks = []
|
|
66
|
+
|
|
67
|
+
messages.each do |msg|
|
|
68
|
+
if msg.tool_result?
|
|
69
|
+
tool_result_blocks << render_tool_result_block(msg)
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
unless tool_result_blocks.empty?
|
|
74
|
+
rendered << { role: 'user', content: tool_result_blocks }
|
|
75
|
+
tool_result_blocks = []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
message = render_non_tool_message(msg)
|
|
79
|
+
rendered << message if message
|
|
28
80
|
end
|
|
81
|
+
|
|
82
|
+
rendered << { role: 'user', content: tool_result_blocks } unless tool_result_blocks.empty?
|
|
83
|
+
rendered
|
|
29
84
|
end
|
|
30
85
|
|
|
31
|
-
|
|
86
|
+
def render_non_tool_message(msg)
|
|
87
|
+
content = render_message_content(msg)
|
|
88
|
+
return nil if content.empty?
|
|
32
89
|
|
|
33
|
-
|
|
34
|
-
|
|
90
|
+
{
|
|
91
|
+
role: render_role(msg.role),
|
|
92
|
+
content: content
|
|
93
|
+
}
|
|
35
94
|
end
|
|
36
95
|
|
|
37
|
-
def
|
|
38
|
-
|
|
96
|
+
def render_message_content(msg)
|
|
97
|
+
if msg.content.is_a?(RubyLLM::Content::Raw)
|
|
98
|
+
return render_raw_content(msg.content) if msg.role == :assistant
|
|
39
99
|
|
|
40
|
-
|
|
41
|
-
|
|
100
|
+
return sanitize_non_assistant_raw_blocks(render_raw_content(msg.content))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
blocks = []
|
|
104
|
+
|
|
105
|
+
thinking_block = render_thinking_block(msg.thinking)
|
|
106
|
+
blocks << thinking_block if msg.role == :assistant && thinking_block
|
|
42
107
|
|
|
43
|
-
|
|
44
|
-
|
|
108
|
+
text_and_media_blocks = Media.render_content(msg.content, used_document_names: @used_document_names)
|
|
109
|
+
blocks.concat(text_and_media_blocks) if text_and_media_blocks
|
|
110
|
+
|
|
111
|
+
if msg.tool_call?
|
|
112
|
+
msg.tool_calls.each_value do |tool_call|
|
|
113
|
+
blocks << {
|
|
114
|
+
toolUse: {
|
|
115
|
+
toolUseId: tool_call.id,
|
|
116
|
+
name: tool_call.name,
|
|
117
|
+
input: tool_call.arguments
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
end
|
|
45
121
|
end
|
|
122
|
+
|
|
123
|
+
blocks
|
|
46
124
|
end
|
|
47
125
|
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
126
|
+
def render_raw_content(content)
|
|
127
|
+
value = content.value
|
|
128
|
+
value.is_a?(Array) ? value : [value]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def sanitize_non_assistant_raw_blocks(blocks)
|
|
132
|
+
blocks.filter_map do |block|
|
|
133
|
+
next unless block.is_a?(Hash)
|
|
134
|
+
next if block.key?(:reasoningContent) || block.key?('reasoningContent')
|
|
135
|
+
|
|
136
|
+
block
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def render_tool_result_block(msg)
|
|
141
|
+
{
|
|
142
|
+
toolResult: {
|
|
143
|
+
toolUseId: msg.tool_call_id,
|
|
144
|
+
content: render_tool_result_content(msg.content)
|
|
145
|
+
}
|
|
53
146
|
}
|
|
147
|
+
end
|
|
54
148
|
|
|
55
|
-
|
|
56
|
-
|
|
149
|
+
def render_tool_result_content(content)
|
|
150
|
+
return render_raw_tool_result_content(content.value) if content.is_a?(RubyLLM::Content::Raw)
|
|
57
151
|
|
|
58
|
-
|
|
152
|
+
if content.is_a?(Hash) || content.is_a?(Array)
|
|
153
|
+
[{ json: content }]
|
|
154
|
+
elsif content.is_a?(RubyLLM::Content)
|
|
155
|
+
blocks = []
|
|
156
|
+
blocks << { text: content.text } if content.text
|
|
157
|
+
content.attachments.each do |attachment|
|
|
158
|
+
blocks << { text: attachment.for_llm }
|
|
159
|
+
end
|
|
160
|
+
blocks
|
|
161
|
+
else
|
|
162
|
+
[{ text: content.to_s }]
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def render_raw_tool_result_content(raw_value)
|
|
167
|
+
blocks = raw_value.is_a?(Array) ? raw_value : [raw_value]
|
|
168
|
+
|
|
169
|
+
normalized = blocks.filter_map do |block|
|
|
170
|
+
normalize_tool_result_block(block)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
normalized.empty? ? [{ text: raw_value.to_s }] : normalized
|
|
59
174
|
end
|
|
60
175
|
|
|
61
|
-
def
|
|
62
|
-
|
|
176
|
+
def normalize_tool_result_block(block)
|
|
177
|
+
return nil unless block.is_a?(Hash)
|
|
178
|
+
return block if tool_result_content_block?(block)
|
|
63
179
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def tool_result_content_block?(block)
|
|
184
|
+
%w[text json document image].any? do |key|
|
|
185
|
+
block.key?(key) || block.key?(key.to_sym)
|
|
67
186
|
end
|
|
187
|
+
end
|
|
68
188
|
|
|
69
|
-
|
|
189
|
+
def render_role(role)
|
|
190
|
+
case role
|
|
191
|
+
when :assistant then 'assistant'
|
|
192
|
+
else 'user'
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def render_system(messages)
|
|
197
|
+
messages.flat_map { |msg| Media.render_content(msg.content, used_document_names: @used_document_names) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def render_inference_config(_model, temperature)
|
|
201
|
+
config = {}
|
|
202
|
+
config[:temperature] = temperature unless temperature.nil?
|
|
203
|
+
config
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def render_tool_config(tools)
|
|
207
|
+
return nil if tools.empty?
|
|
70
208
|
|
|
71
209
|
{
|
|
72
|
-
|
|
73
|
-
content: content_blocks
|
|
210
|
+
tools: tools.values.map { |tool| render_tool(tool) }
|
|
74
211
|
}
|
|
75
212
|
end
|
|
76
213
|
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
214
|
+
def render_tool(tool)
|
|
215
|
+
input_schema = tool.params_schema || RubyLLM::Tool::SchemaDefinition.from_parameters(tool.parameters)&.json_schema
|
|
216
|
+
|
|
217
|
+
tool_spec = {
|
|
218
|
+
toolSpec: {
|
|
219
|
+
name: tool.name,
|
|
220
|
+
description: tool.description,
|
|
221
|
+
inputSchema: {
|
|
222
|
+
json: input_schema || default_input_schema
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return tool_spec if tool.provider_params.empty?
|
|
228
|
+
|
|
229
|
+
RubyLLM::Utils.deep_merge(tool_spec, tool.provider_params)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def render_additional_model_request_fields(thinking)
|
|
233
|
+
fields = {}
|
|
234
|
+
|
|
235
|
+
reasoning_fields = render_reasoning_fields(thinking)
|
|
236
|
+
fields = RubyLLM::Utils.deep_merge(fields, reasoning_fields) if reasoning_fields
|
|
237
|
+
|
|
238
|
+
fields.empty? ? nil : fields
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def render_reasoning_fields(thinking)
|
|
242
|
+
return nil unless thinking&.enabled?
|
|
243
|
+
|
|
244
|
+
effort_config = effort_reasoning_config(thinking)
|
|
245
|
+
return effort_config if effort_config
|
|
246
|
+
|
|
247
|
+
budget_reasoning_config(thinking)
|
|
248
|
+
end
|
|
82
249
|
|
|
83
|
-
|
|
250
|
+
def effort_reasoning_config(thinking)
|
|
251
|
+
effort = thinking.respond_to?(:effort) ? thinking.effort : nil
|
|
252
|
+
effort = effort.to_s if effort
|
|
253
|
+
return nil if effort.nil? || effort.empty? || effort == 'none'
|
|
254
|
+
|
|
255
|
+
if reasoning_embedded?(@model)
|
|
256
|
+
{ reasoning_config: { type: 'enabled', reasoning_effort: effort } }
|
|
257
|
+
else
|
|
258
|
+
{ reasoning_effort: effort }
|
|
84
259
|
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def budget_reasoning_config(thinking)
|
|
263
|
+
budget = thinking.respond_to?(:budget) ? thinking.budget : thinking
|
|
264
|
+
return nil unless budget.is_a?(Integer)
|
|
265
|
+
|
|
266
|
+
{ reasoning_config: { type: 'enabled', budget_tokens: budget } }
|
|
267
|
+
end
|
|
85
268
|
|
|
86
|
-
|
|
87
|
-
|
|
269
|
+
def render_thinking_block(thinking)
|
|
270
|
+
return nil unless thinking
|
|
88
271
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
272
|
+
if thinking.text
|
|
273
|
+
{
|
|
274
|
+
reasoningContent: {
|
|
275
|
+
reasoningText: {
|
|
276
|
+
text: thinking.text,
|
|
277
|
+
signature: thinking.signature
|
|
278
|
+
}.compact
|
|
279
|
+
}
|
|
95
280
|
}
|
|
281
|
+
elsif thinking.signature
|
|
282
|
+
{
|
|
283
|
+
reasoningContent: {
|
|
284
|
+
redactedContent: thinking.signature
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def parse_text_content(content_blocks)
|
|
291
|
+
text = content_blocks.filter_map { |block| block['text'] if block['text'].is_a?(String) }.join
|
|
292
|
+
text.empty? ? nil : text
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def parse_thinking(content_blocks)
|
|
296
|
+
text = +''
|
|
297
|
+
signature = nil
|
|
298
|
+
|
|
299
|
+
content_blocks.each do |block|
|
|
300
|
+
chunk_text, chunk_signature = parse_reasoning_content_block(block)
|
|
301
|
+
text << chunk_text if chunk_text
|
|
302
|
+
signature ||= chunk_signature
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
[text.empty? ? nil : text, signature]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def parse_reasoning_content_block(block)
|
|
309
|
+
reasoning_content = block['reasoningContent']
|
|
310
|
+
return [nil, nil] unless reasoning_content.is_a?(Hash)
|
|
311
|
+
|
|
312
|
+
reasoning_text = reasoning_content['reasoningText'] || {}
|
|
313
|
+
text = reasoning_text['text'].is_a?(String) ? reasoning_text['text'] : nil
|
|
314
|
+
signature = reasoning_text['signature'] if reasoning_text['signature'].is_a?(String)
|
|
315
|
+
signature ||= reasoning_content['redactedContent'] if reasoning_content['redactedContent'].is_a?(String)
|
|
316
|
+
[text, signature]
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def parse_tool_calls(content_blocks)
|
|
320
|
+
tool_calls = {}
|
|
321
|
+
|
|
322
|
+
content_blocks.each do |block|
|
|
323
|
+
tool_use = block['toolUse']
|
|
324
|
+
next unless tool_use
|
|
325
|
+
|
|
326
|
+
tool_call_id = tool_use['toolUseId']
|
|
327
|
+
tool_calls[tool_call_id] = ToolCall.new(
|
|
328
|
+
id: tool_call_id,
|
|
329
|
+
name: tool_use['name'],
|
|
330
|
+
arguments: tool_use['input'] || {}
|
|
331
|
+
)
|
|
96
332
|
end
|
|
97
333
|
|
|
334
|
+
tool_calls.empty? ? nil : tool_calls
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def default_input_schema
|
|
98
338
|
{
|
|
99
|
-
|
|
100
|
-
|
|
339
|
+
'type' => 'object',
|
|
340
|
+
'properties' => {},
|
|
341
|
+
'required' => []
|
|
101
342
|
}
|
|
102
343
|
end
|
|
103
344
|
end
|
|
@@ -3,58 +3,87 @@
|
|
|
3
3
|
module RubyLLM
|
|
4
4
|
module Providers
|
|
5
5
|
class Bedrock
|
|
6
|
-
# Media
|
|
7
|
-
# NOTE: Bedrock does not support url attachments
|
|
6
|
+
# Media formatting for Bedrock Converse content blocks.
|
|
8
7
|
module Media
|
|
9
|
-
extend Anthropic::Media
|
|
10
|
-
|
|
11
8
|
module_function
|
|
12
9
|
|
|
13
|
-
def
|
|
14
|
-
return
|
|
15
|
-
return
|
|
16
|
-
return [
|
|
10
|
+
def render_content(content, used_document_names: nil)
|
|
11
|
+
return [] if empty_content?(content)
|
|
12
|
+
return render_raw_content(content) if content.is_a?(RubyLLM::Content::Raw)
|
|
13
|
+
return [{ text: content.to_json }] if content.is_a?(Hash) || content.is_a?(Array)
|
|
14
|
+
return [{ text: content }] unless content.is_a?(RubyLLM::Content)
|
|
15
|
+
|
|
16
|
+
render_content_object(content, used_document_names || {})
|
|
17
|
+
end
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
def empty_content?(content)
|
|
20
|
+
content.nil? || (content.respond_to?(:empty?) && content.empty?)
|
|
21
|
+
end
|
|
20
22
|
|
|
23
|
+
def render_content_object(content, used_document_names)
|
|
24
|
+
blocks = []
|
|
25
|
+
blocks << { text: content.text } if content.text
|
|
21
26
|
content.attachments.each do |attachment|
|
|
22
|
-
|
|
23
|
-
when :image
|
|
24
|
-
parts << format_image(attachment)
|
|
25
|
-
when :pdf
|
|
26
|
-
parts << format_pdf(attachment)
|
|
27
|
-
when :text
|
|
28
|
-
parts << Anthropic::Media.format_text_file(attachment)
|
|
29
|
-
else
|
|
30
|
-
raise UnsupportedAttachmentError, attachment.type
|
|
31
|
-
end
|
|
27
|
+
blocks << render_attachment(attachment, used_document_names:)
|
|
32
28
|
end
|
|
29
|
+
blocks
|
|
30
|
+
end
|
|
33
31
|
|
|
34
|
-
|
|
32
|
+
def render_raw_content(content)
|
|
33
|
+
value = content.value
|
|
34
|
+
value.is_a?(Array) ? value : [value]
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def
|
|
37
|
+
def render_attachment(attachment, used_document_names:)
|
|
38
|
+
case attachment.type
|
|
39
|
+
when :image
|
|
40
|
+
render_image_attachment(attachment)
|
|
41
|
+
when :pdf
|
|
42
|
+
render_document_attachment(attachment, used_document_names:)
|
|
43
|
+
when :text
|
|
44
|
+
{ text: attachment.for_llm }
|
|
45
|
+
else
|
|
46
|
+
raise UnsupportedAttachmentError, attachment.mime_type
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_image_attachment(attachment)
|
|
38
51
|
{
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
52
|
+
image: {
|
|
53
|
+
format: attachment.format,
|
|
54
|
+
source: {
|
|
55
|
+
bytes: attachment.encoded
|
|
56
|
+
}
|
|
44
57
|
}
|
|
45
58
|
}
|
|
46
59
|
end
|
|
47
60
|
|
|
48
|
-
def
|
|
61
|
+
def render_document_attachment(attachment, used_document_names:)
|
|
62
|
+
document_name = unique_document_name(sanitize_document_name(attachment.filename), used_document_names)
|
|
49
63
|
{
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
64
|
+
document: {
|
|
65
|
+
format: attachment.format,
|
|
66
|
+
name: document_name,
|
|
67
|
+
source: {
|
|
68
|
+
bytes: attachment.encoded
|
|
69
|
+
}
|
|
55
70
|
}
|
|
56
71
|
}
|
|
57
72
|
end
|
|
73
|
+
|
|
74
|
+
def sanitize_document_name(filename)
|
|
75
|
+
base = File.basename(filename.to_s, '.*')
|
|
76
|
+
safe = base.gsub(/[^a-zA-Z0-9_-]/, '_')
|
|
77
|
+
safe.empty? ? 'document' : safe
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def unique_document_name(base_name, used_names)
|
|
81
|
+
count = used_names[base_name].to_i
|
|
82
|
+
used_names[base_name] = count + 1
|
|
83
|
+
return base_name if count.zero?
|
|
84
|
+
|
|
85
|
+
"#{base_name}_#{count + 1}"
|
|
86
|
+
end
|
|
58
87
|
end
|
|
59
88
|
end
|
|
60
89
|
end
|