ai-chat 0.2.3 → 0.2.4

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: c2397a7d43d950bf70abd84c83d0e57b2cadb70fc127985fae4da4a9ecf142b8
4
- data.tar.gz: d44a9d1999ce48cd0af92f367f5f0e2f586b8c19d1fc9e95ce8c7aa7e3fffa84
3
+ metadata.gz: 12727b49e500d9c3c117b0d8a4df291f0b050eae0b12c90d7cd992ac93881c42
4
+ data.tar.gz: 2ebf913f409bdfbebcc5cd12d8a4a87346be894564d75a6c7811157b9619ecce
5
5
  SHA512:
6
- metadata.gz: fdb13f4e405f46a21591f71ae818544bc60f7bc083e4b3eb9a8a6d57b9eba03995fe8b75828f3972e564dfa3ed3ed09aed4a8b7ae2260b3602cbede9d80afd8e
7
- data.tar.gz: 427b5db79e006d0f6f9b20b83472a78c3234118807e4d43d472304426ca2d80d5b189d029924cd548d7004206914a4e63fd52ca93b1944e55e81276309a501d9
6
+ metadata.gz: 3707fdf80a68ce541b0c4fb3ab362f2404e2b0e78a3e7bd86ec140702f4b2939a6c2740b12a58d39212aceb24607c87648948f5d93bb5fcacffbf7200e35e2e0
7
+ data.tar.gz: 9d3e23578c90251da1fb08351aafe48fa5c7a82ec330644a6cffbe156fd42d58bcfbebd42be93df5a7a680cddfe0855c8dc2770ef3c8a0a70f2c4d4e8fa894f8
data/README.md CHANGED
@@ -82,7 +82,7 @@ pp a.messages
82
82
  # => [{:role=>"user", :content=>"If the Ruby community had an official motto, what might it be?"}]
83
83
 
84
84
  # Generate the next message using AI
85
- a.generate! # => "Matz is nice and so we are nice" (or similar)
85
+ a.generate! # => { :role => "assistant", :content => "Matz is nice and so we are nice" (or similar) }
86
86
 
87
87
  # Your array now includes the assistant's response
88
88
  pp a.messages
@@ -93,7 +93,7 @@ pp a.messages
93
93
 
94
94
  # Continue the conversation
95
95
  a.add("What about Rails?")
96
- a.generate! # => "Convention over configuration."
96
+ a.generate! # => { :role => "assistant", :content => "Convention over configuration."}
97
97
  ```
98
98
 
99
99
  ## Understanding the Data Structure
@@ -135,7 +135,7 @@ pp b.messages
135
135
  # ]
136
136
 
137
137
  # Generate a response
138
- b.generate! # => "Methinks 'tis 'Ruby doth bring joy to all who craft with care'"
138
+ b.generate! # => { :role => "assistant", :content => "Methinks 'tis 'Ruby doth bring joy to all who craft with care'" }
139
139
  ```
140
140
 
141
141
  ### Convenience Methods
@@ -237,7 +237,7 @@ h.messages.last[:content]
237
237
  # => "Here's how to boil an egg..."
238
238
 
239
239
  # Or use the convenient shortcut
240
- h.last
240
+ h.last[:content]
241
241
  # => "Here's how to boil an egg..."
242
242
  ```
243
243
 
@@ -277,10 +277,11 @@ i.schema = '{"name": "nutrition_values","strict": true,"schema": {"type": "objec
277
277
  i.user("1 slice of pizza")
278
278
 
279
279
  response = i.generate!
280
+ data = response[:content]
280
281
  # => {:fat=>15, :protein=>12, :carbs=>35, :total_calories=>285}
281
282
 
282
283
  # The response is parsed JSON, not a string!
283
- response[:total_calories] # => 285
284
+ data[:total_calories] # => 285
284
285
  ```
285
286
 
286
287
  ### Schema Formats
@@ -442,14 +443,14 @@ a = AI::Chat.new
442
443
  a.user("What color is the object in this photo?", image: "thing.png")
443
444
  a.generate! # => "Red"
444
445
  a.user("What is the object in the photo?")
445
- a.generate! # => "I don't see a photo"
446
+ a.generate! # => { :content => "I don't see a photo", ... }
446
447
 
447
448
  b = AI::Chat.new
448
449
  b.user("What color is the object in this photo?", image: "thing.png")
449
450
  b.generate! # => "Red"
450
451
  b.user("What is the object in the photo?")
451
452
  b.previous_response_id = nil
452
- b.generate! # => "An apple"
453
+ b.generate! # => { :content => "An apple", ... }
453
454
  ```
454
455
 
455
456
  If you don't set `previous_response_id` to `nil`, the model won't have the old image(s) to work with.
@@ -462,7 +463,7 @@ You can enable OpenAI's image generation tool:
462
463
  a = AI::Chat.new
463
464
  a.image_generation = true
464
465
  a.user("Draw a picture of a kitten")
465
- a.generate! # => "Here is your picture of a kitten:"
466
+ a.generate! # => { :content => "Here is your picture of a kitten:", ... }
466
467
  ```
467
468
 
468
469
  By default, images are saved to `./images`. You can configure a different location:
@@ -472,7 +473,7 @@ a = AI::Chat.new
472
473
  a.image_generation = true
473
474
  a.image_folder = "./my_images"
474
475
  a.user("Draw a picture of a kitten")
475
- a.generate! # => "Here is your picture of a kitten:"
476
+ a.generate! # => { :content => "Here is your picture of a kitten:", ... }
476
477
  ```
477
478
 
478
479
  Images are saved in timestamped subfolders using ISO 8601 basic format. For example:
@@ -510,9 +511,18 @@ a = AI::Chat.new
510
511
  a.image_generation = true
511
512
  a.image_folder = "./images"
512
513
  a.user("Draw a picture of a kitten")
513
- a.generate! # => "Here is a picture of a kitten:"
514
+ a.generate! # => { :content => "Here is a picture of a kitten:", ... }
514
515
  a.user("Make it even cuter")
515
- a.generate! # => "Here is the kitten, but even cuter:"
516
+ a.generate! # => { :content => "Here is the kitten, but even cuter:", ... }
517
+ ```
518
+
519
+ ## Code Interpreter
520
+
521
+ ```ruby
522
+ y = AI::Chat.new
523
+ y.code_interpreter = true
524
+ y.user("Plot y = 2x*3 when x is -5 to 5.")
525
+ y.generate! # => {:content => "Here is the graph.", ... }
516
526
  ```
517
527
 
518
528
  ## Building Conversations Without API Calls
data/ai-chat.gemspec CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = "ai-chat"
5
- spec.version = "0.2.3"
5
+ spec.version = "0.2.4"
6
6
  spec.authors = ["Raghu Betina"]
7
7
  spec.email = ["raghu@firstdraft.com"]
8
8
  spec.homepage = "https://github.com/firstdraft/ai-chat"
@@ -23,6 +23,7 @@ Gem::Specification.new do |spec|
23
23
  spec.add_runtime_dependency "marcel", "~> 1.0"
24
24
  spec.add_runtime_dependency "base64", "> 0.1.1"
25
25
  spec.add_runtime_dependency "json", "~> 2.0"
26
+ spec.add_runtime_dependency "tty-spinner", "~> 0.9.3"
26
27
 
27
28
  spec.add_development_dependency "dotenv"
28
29
  spec.add_development_dependency "refinements", "~> 11.1"
@@ -1,5 +1,5 @@
1
1
  require "amazing_print"
2
-
2
+ # :reek:IrresponsibleModule
3
3
  module AmazingPrint
4
4
  module AI
5
5
  def self.included(base)
@@ -27,6 +27,10 @@ module AmazingPrint
27
27
  end
28
28
  end
29
29
 
30
+ # :reek:DuplicateMethodCall
31
+ # :reek:FeatureEnvy
32
+ # :reek:NilCheck
33
+ # :reek:TooManyStatements
30
34
  def format_ai_chat(chat)
31
35
  vars = []
32
36
 
@@ -53,6 +57,8 @@ module AmazingPrint
53
57
  format_object(chat, vars)
54
58
  end
55
59
 
60
+ # :reek:TooManyStatements
61
+ # :reek:DuplicateMethodCall
56
62
  def format_object(object, vars)
57
63
  data = vars.map do |(name, value)|
58
64
  name = colorize(name, :variable) unless @options[:plain]
data/lib/ai/chat.rb CHANGED
@@ -7,6 +7,8 @@ require "openai"
7
7
  require "pathname"
8
8
  require "stringio"
9
9
  require "fileutils"
10
+ require "tty-spinner"
11
+ require "timeout"
10
12
 
11
13
  module AI
12
14
  # :reek:MissingSafeMethod { exclude: [ generate! ] }
@@ -16,7 +18,7 @@ module AI
16
18
  # :reek:IrresponsibleModule
17
19
  class Chat
18
20
  # :reek:Attribute
19
- attr_accessor :messages, :model, :web_search, :previous_response_id, :image_generation, :image_folder
21
+ attr_accessor :background, :code_interpreter, :image_generation, :image_folder, :messages, :model, :previous_response_id, :web_search
20
22
  attr_reader :reasoning_effort, :client, :schema
21
23
 
22
24
  VALID_REASONING_EFFORTS = [:low, :medium, :high].freeze
@@ -34,16 +36,16 @@ module AI
34
36
 
35
37
  # :reek:TooManyStatements
36
38
  # :reek:NilCheck
37
- def add(content, role: "user", response: nil, image: nil, images: nil, file: nil, files: nil)
39
+ def add(content, role: "user", response: nil, status: nil, image: nil, images: nil, file: nil, files: nil)
38
40
  if image.nil? && images.nil? && file.nil? && files.nil?
39
- messages.push(
40
- {
41
- role: role,
42
- content: content,
43
- response: response
44
- }.compact
45
- )
46
-
41
+ message = {
42
+ role: role,
43
+ content: content,
44
+ response: response
45
+ }
46
+ message[:content] = content if content
47
+ message[:status] = status if status
48
+ messages.push(message)
47
49
  else
48
50
  text_and_files_array = [
49
51
  {
@@ -76,7 +78,8 @@ module AI
76
78
  messages.push(
77
79
  {
78
80
  role: role,
79
- content: text_and_files_array
81
+ content: text_and_files_array,
82
+ status: status
80
83
  }
81
84
  )
82
85
  end
@@ -90,53 +93,31 @@ module AI
90
93
  add(message, role: "user", image: image, images: images, file: file, files: files)
91
94
  end
92
95
 
93
- def assistant(message, response: nil)
94
- add(message, role: "assistant", response: response)
96
+ def assistant(message, response: nil, status: nil)
97
+ add(message, role: "assistant", response: response, status: status)
95
98
  end
96
99
 
97
100
  # :reek:NilCheck
98
101
  # :reek:TooManyStatements
99
102
  def generate!
100
103
  response = create_response
104
+ parse_response(response)
101
105
 
102
- text_response = extract_text_from_response(response)
103
-
104
- image_filenames = extract_and_save_images(response)
105
- response_usage = response.usage.to_h.slice(:input_tokens, :output_tokens, :total_tokens)
106
-
107
- chat_response = {
108
- id: response.id,
109
- model: response.model,
110
- usage: response_usage,
111
- total_tokens: response_usage[:total_tokens],
112
- images: image_filenames
113
- }
114
-
115
- message = if schema
116
- if text_response.nil? || text_response.empty?
117
- raise ArgumentError, "No text content in response to parse as JSON for schema: #{schema.inspect}"
118
- end
119
- JSON.parse(text_response, symbolize_names: true)
120
- else
121
- text_response
122
- end
106
+ self.previous_response_id = last.dig(:response, :id)
107
+ last
108
+ end
123
109
 
124
- if image_filenames.empty?
125
- assistant(message, response: chat_response)
110
+ # :reek:BooleanParameter
111
+ # :reek:ControlParameter
112
+ # :reek:DuplicateMethodCall
113
+ # :reek:TooManyStatements
114
+ def get_response(wait: false, timeout: 600)
115
+ response = if wait
116
+ wait_for_response(timeout)
126
117
  else
127
- messages.push(
128
- {
129
- role: "assistant",
130
- content: message,
131
- images: image_filenames,
132
- response: chat_response
133
- }.compact
134
- )
118
+ client.responses.retrieve(previous_response_id)
135
119
  end
136
-
137
- self.previous_response_id = chat_response[:id]
138
-
139
- message
120
+ parse_response(response)
140
121
  end
141
122
 
142
123
  # :reek:NilCheck
@@ -177,6 +158,11 @@ module AI
177
158
  end
178
159
 
179
160
  # Support for Ruby's pp (pretty print)
161
+ # :reek:TooManyStatements
162
+ # :reek:NilCheck
163
+ # :reek:FeatureEnvy
164
+ # :reek:DuplicateMethodCall
165
+ # :reek:UncommunicativeParameterName
180
166
  def pretty_print(q)
181
167
  q.group(1, "#<#{self.class}", '>') do
182
168
  q.breakable
@@ -207,7 +193,6 @@ module AI
207
193
  end
208
194
  end
209
195
 
210
-
211
196
  private
212
197
 
213
198
  class InputClassificationError < StandardError; end
@@ -231,6 +216,7 @@ module AI
231
216
  model: model
232
217
  }
233
218
 
219
+ parameters[:background] = background if background
234
220
  parameters[:tools] = tools unless tools.empty?
235
221
  parameters[:text] = schema if schema
236
222
  parameters[:reasoning] = {effort: reasoning_effort} if reasoning_effort
@@ -242,6 +228,56 @@ module AI
242
228
  client.responses.create(**parameters)
243
229
  end
244
230
 
231
+ # :reek:NilCheck
232
+ # :reek:TooManyStatements
233
+ def parse_response(response)
234
+ text_response = response.output_text
235
+ image_filenames = extract_and_save_images(response)
236
+ response_id = response.id
237
+ response_usage = response.usage.to_h.slice(:input_tokens, :output_tokens, :total_tokens)
238
+
239
+ chat_response = {
240
+ id: response_id,
241
+ model: response.model,
242
+ usage: response_usage,
243
+ total_tokens: response_usage[:total_tokens],
244
+ images: image_filenames
245
+ }.compact
246
+
247
+ response_content = if schema
248
+ if text_response.nil? || text_response.empty?
249
+ raise ArgumentError, "No text content in response to parse as JSON for schema: #{schema.inspect}"
250
+ end
251
+ JSON.parse(text_response, symbolize_names: true)
252
+ else
253
+ text_response
254
+ end
255
+
256
+ existing_message_position = messages.find_index do |message|
257
+ message.dig(:response, :id) == response_id
258
+ end
259
+
260
+ message = {
261
+ role: "assistant",
262
+ content: response_content,
263
+ response: chat_response,
264
+ status: response.status
265
+ }
266
+
267
+ message.store(:images, image_filenames) unless image_filenames.empty?
268
+
269
+ if existing_message_position
270
+ messages[existing_message_position] = message
271
+ else
272
+ messages.push(message)
273
+ message
274
+ end
275
+ end
276
+
277
+ def cancel_request
278
+ client.responses.cancel(previous_response_id)
279
+ end
280
+
245
281
  def prepare_messages_for_api
246
282
  return messages unless previous_response_id
247
283
 
@@ -389,19 +425,12 @@ module AI
389
425
  if image_generation
390
426
  tools_list << {type: "image_generation"}
391
427
  end
428
+ if code_interpreter
429
+ tools_list << {type: "code_interpreter", container: {type: "auto"}}
430
+ end
392
431
  tools_list
393
432
  end
394
433
 
395
- # :reek:UtilityFunction
396
- # :reek:ManualDispatch
397
- def extract_text_from_response(response)
398
- response.output.flat_map { |output|
399
- output.respond_to?(:content) ? output.content : []
400
- }.compact.find { |content|
401
- content.is_a?(OpenAI::Models::Responses::ResponseOutputText)
402
- }&.text
403
- end
404
-
405
434
  # :reek:FeatureEnvy
406
435
  # :reek:UtilityFunction
407
436
  def wrap_schema_if_needed(schema)
@@ -438,31 +467,129 @@ module AI
438
467
 
439
468
  return image_filenames if image_outputs.empty?
440
469
 
441
- # ISO 8601 basic format with centisecond precision
442
- timestamp = Time.now.strftime("%Y%m%dT%H%M%S%2N")
443
-
444
- subfolder_name = "#{timestamp}_#{response.id}"
445
- subfolder_path = File.join(image_folder || "./images", subfolder_name)
446
- FileUtils.mkdir_p(subfolder_path)
470
+ subfolder_path = create_images_folder(response.id)
447
471
 
448
472
  image_outputs.each_with_index do |output, index|
449
473
  next unless output.respond_to?(:result) && output.result
450
474
 
451
- begin
475
+ warn_if_file_fails_to_save do
452
476
  image_data = Base64.strict_decode64(output.result)
453
477
 
454
478
  filename = "#{(index + 1).to_s.rjust(3, "0")}.png"
455
- filepath = File.join(subfolder_path, filename)
479
+ file_path = File.join(subfolder_path, filename)
456
480
 
457
- File.binwrite(filepath, image_data)
481
+ File.binwrite(file_path, image_data)
458
482
 
459
- image_filenames << filepath
460
- rescue => error
461
- warn "Failed to save image: #{error.message}"
483
+ image_filenames << file_path
462
484
  end
463
485
  end
464
486
 
465
487
  image_filenames
466
488
  end
489
+
490
+ def create_images_folder(response_id)
491
+ # ISO 8601 basic format with centisecond precision
492
+ timestamp = Time.now.strftime("%Y%m%dT%H%M%S%2N")
493
+
494
+ subfolder_name = "#{timestamp}_#{response_id}"
495
+ subfolder_path = File.join(image_folder || "./images", subfolder_name)
496
+ FileUtils.mkdir_p(subfolder_path)
497
+ subfolder_path
498
+ end
499
+
500
+ def warn_if_file_fails_to_save
501
+ begin
502
+ yield
503
+ rescue => error
504
+ warn "Failed to save image: #{error.message}"
505
+ end
506
+ end
507
+
508
+ # :reek:FeatureEnvy
509
+ # :reek:ManualDispatch
510
+ # :reek:NestedIterators
511
+ # :reek:TooManyStatements
512
+ def extract_and_save_files(response)
513
+ filenames = []
514
+
515
+ message_outputs = response.output.select do |output|
516
+ output.respond_to?(:type) && output.type == :message
517
+ end
518
+
519
+ outputs_with_annotations = message_outputs.map do |message|
520
+ message.content.find do |content|
521
+ content.respond_to?(:annotations) && content.annotations.length.positive?
522
+ end
523
+ end.compact
524
+
525
+ return filenames if outputs_with_annotations.empty?
526
+
527
+ subfolder_path = create_images_folder(response.id)
528
+ annotations = outputs_with_annotations.map do |output|
529
+ output.annotations.find do |annotation|
530
+ annotation.respond_to?(:filename)
531
+ end
532
+ end.compact
533
+
534
+ annotations.each do |annotation|
535
+ container_id = annotation.container_id
536
+ file_id = annotation.file_id
537
+ filename = annotation.filename
538
+
539
+ warn_if_file_fails_to_save do
540
+ container_content = client.containers.files.content
541
+ file_content = container_content.retrieve(file_id, container_id: container_id)
542
+ file_path = File.join(subfolder_path, filename)
543
+ File.open(file_path, "wb") do |file|
544
+ file.write(file_content.read)
545
+ end
546
+ filenames << file_path
547
+ end
548
+ end
549
+
550
+ filenames
551
+ end
552
+
553
+ # This is similar to ActiveJob's :polynomially_longer retry option
554
+ # :reek:DuplicateMethodCall
555
+ # :reek:UtilityFunction
556
+ def calculate_wait(executions)
557
+ # cap the maximum wait time to ~110 seconds
558
+ executions = executions.clamp(1..10)
559
+ jitter = 0.15
560
+ ((executions**2) + (Kernel.rand * (executions**2) * jitter)) + 2
561
+ end
562
+
563
+ def timeout_request(duration)
564
+ begin
565
+ Timeout.timeout(duration) do
566
+ yield
567
+ end
568
+ rescue Timeout::Error
569
+ client.responses.cancel(previous_response_id)
570
+ end
571
+ end
572
+
573
+ # :reek:DuplicateMethodCall
574
+ # :reek:TooManyStatements
575
+ def wait_for_response(timeout)
576
+ spinner = TTY::Spinner.new("[:spinner] Thinking ...", format: :dots)
577
+ spinner.auto_spin
578
+ api_response = client.responses.retrieve(previous_response_id)
579
+ number_of_times_polled = 0
580
+ response = timeout_request(timeout) do
581
+ while api_response.status != :completed
582
+ some_amount_of_seconds = calculate_wait(number_of_times_polled)
583
+ sleep some_amount_of_seconds
584
+ number_of_times_polled += 1
585
+ api_response = client.responses.retrieve(previous_response_id)
586
+ end
587
+ api_response
588
+ end
589
+
590
+ exit_message = response.status == :cancelled ? "request timed out" : "done!"
591
+ spinner.stop(exit_message)
592
+ response
593
+ end
467
594
  end
468
595
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai-chat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raghu Betina
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-08-18 00:00:00.000000000 Z
11
+ date: 2025-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: openai
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '2.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: tty-spinner
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 0.9.3
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 0.9.3
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: dotenv
71
85
  requirement: !ruby/object:Gem::Requirement