ruby_llm 1.11.0 → 1.12.1
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 +12 -0
- data/lib/ruby_llm/active_record/acts_as.rb +0 -2
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +97 -27
- data/lib/ruby_llm/active_record/chat_methods.rb +73 -19
- data/lib/ruby_llm/agent.rb +326 -0
- data/lib/ruby_llm/aliases.json +47 -29
- data/lib/ruby_llm/chat.rb +27 -3
- data/lib/ruby_llm/configuration.rb +3 -0
- data/lib/ruby_llm/content.rb +6 -0
- data/lib/ruby_llm/models.json +19090 -5190
- data/lib/ruby_llm/models.rb +35 -6
- data/lib/ruby_llm/provider.rb +8 -0
- 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 +296 -64
- 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/version.rb +1 -1
- data/lib/ruby_llm.rb +4 -0
- data/lib/tasks/models.rake +10 -5
- data/lib/tasks/vcr.rake +32 -0
- metadata +17 -17
- 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,112 +3,344 @@
|
|
|
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
|
-
response = connection.post completion_url, payload do |req|
|
|
13
|
-
req.headers.merge! build_headers(signature.headers, streaming: block_given?)
|
|
14
|
-
req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
|
|
15
|
-
end
|
|
16
|
-
Anthropic::Chat.parse_completion_response response
|
|
10
|
+
def completion_url
|
|
11
|
+
"/model/#{@model.id}/converse"
|
|
17
12
|
end
|
|
18
13
|
|
|
19
|
-
def
|
|
20
|
-
|
|
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 }
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
28
32
|
end
|
|
33
|
+
|
|
34
|
+
additional_fields = render_additional_model_request_fields(thinking)
|
|
35
|
+
payload[:additionalModelRequestFields] = additional_fields if additional_fields
|
|
36
|
+
|
|
37
|
+
payload
|
|
29
38
|
end
|
|
30
39
|
|
|
31
|
-
|
|
40
|
+
def parse_completion_response(response)
|
|
41
|
+
data = response.body
|
|
42
|
+
return if data.nil? || data.empty?
|
|
32
43
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
)
|
|
35
61
|
end
|
|
36
62
|
|
|
37
|
-
def
|
|
38
|
-
|
|
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
|
|
39
72
|
|
|
40
|
-
|
|
41
|
-
|
|
73
|
+
unless tool_result_blocks.empty?
|
|
74
|
+
rendered << { role: 'user', content: tool_result_blocks }
|
|
75
|
+
tool_result_blocks = []
|
|
76
|
+
end
|
|
42
77
|
|
|
43
|
-
|
|
44
|
-
|
|
78
|
+
message = render_non_tool_message(msg)
|
|
79
|
+
rendered << message if message
|
|
45
80
|
end
|
|
81
|
+
|
|
82
|
+
rendered << { role: 'user', content: tool_result_blocks } unless tool_result_blocks.empty?
|
|
83
|
+
rendered
|
|
46
84
|
end
|
|
47
85
|
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
86
|
+
def render_non_tool_message(msg)
|
|
87
|
+
content = render_message_content(msg)
|
|
88
|
+
return nil if content.empty?
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
role: render_role(msg.role),
|
|
92
|
+
content: content
|
|
53
93
|
}
|
|
94
|
+
end
|
|
54
95
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
57
99
|
|
|
58
|
-
|
|
59
|
-
|
|
100
|
+
return sanitize_non_assistant_raw_blocks(render_raw_content(msg.content))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
blocks = []
|
|
60
104
|
|
|
61
|
-
|
|
62
|
-
|
|
105
|
+
thinking_block = render_thinking_block(msg.thinking)
|
|
106
|
+
blocks << thinking_block if msg.role == :assistant && thinking_block
|
|
63
107
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
67
121
|
end
|
|
68
122
|
|
|
69
|
-
|
|
123
|
+
blocks
|
|
124
|
+
end
|
|
70
125
|
|
|
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)
|
|
71
141
|
{
|
|
72
|
-
|
|
73
|
-
|
|
142
|
+
toolResult: {
|
|
143
|
+
toolUseId: msg.tool_call_id,
|
|
144
|
+
content: render_tool_result_content(msg.content)
|
|
145
|
+
}
|
|
74
146
|
}
|
|
75
147
|
end
|
|
76
148
|
|
|
77
|
-
def
|
|
78
|
-
if
|
|
79
|
-
content_blocks = msg.content.value
|
|
80
|
-
content_blocks = [content_blocks] unless content_blocks.is_a?(Array)
|
|
81
|
-
content_blocks = Anthropic::Chat.prepend_thinking_block(content_blocks, msg, thinking_enabled)
|
|
149
|
+
def render_tool_result_content(content)
|
|
150
|
+
return render_raw_tool_result_content(content.value) if content.is_a?(RubyLLM::Content::Raw)
|
|
82
151
|
|
|
83
|
-
|
|
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 }]
|
|
84
163
|
end
|
|
164
|
+
end
|
|
85
165
|
|
|
86
|
-
|
|
87
|
-
|
|
166
|
+
def render_raw_tool_result_content(raw_value)
|
|
167
|
+
blocks = raw_value.is_a?(Array) ? raw_value : [raw_value]
|
|
88
168
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def normalize_tool_result_block(block)
|
|
177
|
+
return nil unless block.is_a?(Hash)
|
|
178
|
+
return block if tool_result_content_block?(block)
|
|
179
|
+
|
|
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)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def render_role(role)
|
|
190
|
+
case role
|
|
191
|
+
when :assistant then 'assistant'
|
|
192
|
+
else 'user'
|
|
96
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?
|
|
97
208
|
|
|
98
209
|
{
|
|
99
|
-
|
|
100
|
-
content: content_blocks
|
|
210
|
+
tools: tools.values.map { |tool| render_tool(tool) }
|
|
101
211
|
}
|
|
102
212
|
end
|
|
103
213
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
249
|
+
|
|
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 } }
|
|
108
257
|
else
|
|
109
|
-
|
|
258
|
+
{ reasoning_effort: effort }
|
|
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
|
|
268
|
+
|
|
269
|
+
def render_thinking_block(thinking)
|
|
270
|
+
return nil unless thinking
|
|
271
|
+
|
|
272
|
+
if thinking.text
|
|
273
|
+
{
|
|
274
|
+
reasoningContent: {
|
|
275
|
+
reasoningText: {
|
|
276
|
+
text: thinking.text,
|
|
277
|
+
signature: thinking.signature
|
|
278
|
+
}.compact
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
elsif thinking.signature
|
|
282
|
+
{
|
|
283
|
+
reasoningContent: {
|
|
284
|
+
redactedContent: thinking.signature
|
|
285
|
+
}
|
|
286
|
+
}
|
|
110
287
|
end
|
|
111
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
|
+
)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
tool_calls.empty? ? nil : tool_calls
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def default_input_schema
|
|
338
|
+
{
|
|
339
|
+
'type' => 'object',
|
|
340
|
+
'properties' => {},
|
|
341
|
+
'required' => []
|
|
342
|
+
}
|
|
343
|
+
end
|
|
112
344
|
end
|
|
113
345
|
end
|
|
114
346
|
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
|