ai-chat 0.4.0 → 0.5.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: 975e7f80044ac46d72ad1c08e290d7fa71b43048fe76d68784d3961c36efde95
4
- data.tar.gz: cf0a2b5fcee3e6ee413580419c992efe6c50e5935be875f2e82d8a740c7d15fb
3
+ metadata.gz: ece58b865d212ce788931ee3e7322b457bbe6ed8495e4374b55ecf697b44e55c
4
+ data.tar.gz: dd4bf5e24a5190eba63c662d93dcaaad24886b7f16cfbec5aebc37a9f3530cd9
5
5
  SHA512:
6
- metadata.gz: fe727e64a0388922db85c3085dea85c6622b88c886cbb25fc660dc9bad8291205a1904a19f611457ac83cbe673a299db4d97f0b658e236de0e3ccccb46f44aac
7
- data.tar.gz: 4be9d9a80ea39e20ef2c11d8142f9403b7b43f46c8f4ccc5a80f88a027980be11b1e882165e67e0380d2a8982e0e4bbd3489585cac3c4a0a291d46a75813d53d
6
+ metadata.gz: dcb3587c00724b1da3d04adb2b9a7413225680b80971bd08db127edab6d7f18e52958b7511f3aa7e13cb95f40961f8889cae3f0cc892913930547d5a7887d452
7
+ data.tar.gz: e831f86fecfa31cf28df66910fc6ca98c66a9ad8290709431a0e5e9b8a6e31965ba7af9d5a00dbcf3f907427abe8901abf23a1cd136b26bc2ebffe260b6334ed
data/README.md CHANGED
@@ -39,6 +39,7 @@ The `examples/` directory contains focused examples for specific features:
39
39
  - `13_conversation_features_comprehensive.rb` - Conversation features (auto-creation, continuity, inspection)
40
40
  - `14_schema_generation.rb` - Generate JSON schemas from natural language
41
41
  - `15_proxy.rb` - Proxy support for student accounts
42
+ - `16_get_items.rb` - Inspecting conversation items (reasoning, web searches, image generation)
42
43
 
43
44
  Each example is self-contained and can be run individually:
44
45
  ```bash
@@ -83,22 +84,44 @@ a = AI::Chat.new
83
84
  a.add("If the Ruby community had an official motto, what might it be?")
84
85
 
85
86
  # See the convo so far - it's just an array of hashes!
86
- pp a.messages
87
- # => [{:role=>"user", :content=>"If the Ruby community had an official motto, what might it be?"}]
87
+ a.messages
88
+ # => [
89
+ # {
90
+ # :role => "user",
91
+ # :content => "If the Ruby community had an official motto, what might it be?"
92
+ # }
93
+ # ]
88
94
 
89
95
  # Generate the next message using AI
90
- a.generate! # => { :role => "assistant", :content => "Matz is nice and so we are nice" (or similar) }
96
+ a.generate!
97
+ # => {
98
+ # :role => "assistant",
99
+ # :content => "Matz is nice and so we are nice",
100
+ # :response => { ... }
101
+ # }
91
102
 
92
103
  # Your array now includes the assistant's response
93
- pp a.messages
104
+ a.messages
94
105
  # => [
95
- # {:role=>"user", :content=>"If the Ruby community had an official motto, what might it be?"},
96
- # {:role=>"assistant", :content=>"Matz is nice and so we are nice", :response => { id=resp_abc... model=gpt-5.1 tokens=12 } }
106
+ # {
107
+ # :role => "user",
108
+ # :content => "If the Ruby community had an official motto, what might it be?"
109
+ # },
110
+ # {
111
+ # :role => "assistant",
112
+ # :content => "Matz is nice and so we are nice",
113
+ # :response => { id: "resp_abc...", model: "gpt-5.2", ... }
114
+ # }
97
115
  # ]
98
116
 
99
117
  # Continue the conversation
100
118
  a.add("What about Rails?")
101
- a.generate! # => { :role => "assistant", :content => "Convention over configuration."}
119
+ a.generate!
120
+ # => {
121
+ # :role => "assistant",
122
+ # :content => "Convention over configuration.",
123
+ # :response => { ... }
124
+ # }
102
125
  ```
103
126
 
104
127
  ## Understanding the Data Structure
@@ -111,9 +134,19 @@ That's it! You're building something like this:
111
134
 
112
135
  ```ruby
113
136
  [
114
- {:role => "system", :content => "You are a helpful assistant"},
115
- {:role => "user", :content => "Hello!"},
116
- {:role => "assistant", :content => "Hi there! How can I help you today?", :response => { id=resp_abc... model=gpt-5.1 tokens=12 } }
137
+ {
138
+ :role => "system",
139
+ :content => "You are a helpful assistant"
140
+ },
141
+ {
142
+ :role => "user",
143
+ :content => "Hello!"
144
+ },
145
+ {
146
+ :role => "assistant",
147
+ :content => "Hi there! How can I help you today?",
148
+ :response => { id: "resp_abc...", model: "gpt-5.2", ... }
149
+ }
117
150
  ]
118
151
  ```
119
152
 
@@ -133,14 +166,25 @@ b.add("You are a helpful assistant that talks like Shakespeare.", role: "system"
133
166
  b.add("If the Ruby community had an official motto, what might it be?")
134
167
 
135
168
  # Check what we've built
136
- pp b.messages
169
+ b.messages
137
170
  # => [
138
- # {:role=>"system", :content=>"You are a helpful assistant that talks like Shakespeare."},
139
- # {:role=>"user", :content=>"If the Ruby community had an official motto, what might it be?"}
171
+ # {
172
+ # :role => "system",
173
+ # :content => "You are a helpful assistant that talks like Shakespeare."
174
+ # },
175
+ # {
176
+ # :role => "user",
177
+ # :content => "If the Ruby community had an official motto, what might it be?"
178
+ # }
140
179
  # ]
141
180
 
142
181
  # Generate a response
143
- b.generate! # => { :role => "assistant", :content => "Methinks 'tis 'Ruby doth bring joy to all who craft with care'" }
182
+ b.generate!
183
+ # => {
184
+ # :role => "assistant",
185
+ # :content => "Methinks 'tis 'Ruby doth bring joy to all who craft with care'",
186
+ # :response => { ... }
187
+ # }
144
188
  ```
145
189
 
146
190
  ### Convenience Methods
@@ -183,7 +227,7 @@ d.generate! # Generate a response
183
227
 
184
228
  ### Model
185
229
 
186
- By default, the gem uses OpenAI's `gpt-5.1` model. If you want to use a different model, you can set it:
230
+ By default, the gem uses OpenAI's `gpt-5.2` model. If you want to use a different model, you can set it:
187
231
 
188
232
  ```ruby
189
233
  e = AI::Chat.new
@@ -219,11 +263,20 @@ h.user("How do I boil an egg?")
219
263
  h.generate!
220
264
 
221
265
  # See the whole conversation
222
- pp h.messages
266
+ h.messages
223
267
  # => [
224
- # {:role=>"system", :content=>"You are a helpful cooking assistant"},
225
- # {:role=>"user", :content=>"How do I boil an egg?"},
226
- # {:role=>"assistant", :content=>"Here's how to boil an egg..."}
268
+ # {
269
+ # :role => "system",
270
+ # :content => "You are a helpful cooking assistant"
271
+ # },
272
+ # {
273
+ # :role => "user",
274
+ # :content => "How do I boil an egg?"
275
+ # },
276
+ # {
277
+ # :role => "assistant",
278
+ # :content => "Here's how to boil an egg..."
279
+ # }
227
280
  # ]
228
281
 
229
282
  # Get just the last response
@@ -460,7 +513,11 @@ You can enable OpenAI's image generation tool:
460
513
  a = AI::Chat.new
461
514
  a.image_generation = true
462
515
  a.user("Draw a picture of a kitten")
463
- a.generate! # => { :content => "Here is your picture of a kitten:", ... }
516
+ a.generate!
517
+ # => {
518
+ # :content => "Here is your picture of a kitten:",
519
+ # :response => { ... }
520
+ # }
464
521
  ```
465
522
 
466
523
  By default, images are saved to `./images`. You can configure a different location:
@@ -470,7 +527,11 @@ a = AI::Chat.new
470
527
  a.image_generation = true
471
528
  a.image_folder = "./my_images"
472
529
  a.user("Draw a picture of a kitten")
473
- a.generate! # => { :content => "Here is your picture of a kitten:", ... }
530
+ a.generate!
531
+ # => {
532
+ # :content => "Here is your picture of a kitten:",
533
+ # :response => { ... }
534
+ # }
474
535
  ```
475
536
 
476
537
  Images are saved in timestamped subfolders using ISO 8601 basic format. For example:
@@ -482,11 +543,19 @@ The folder structure ensures images are organized chronologically and by respons
482
543
  The messages array will now look like this:
483
544
 
484
545
  ```ruby
485
- pp a.messages
546
+ a.messages
486
547
  # => [
487
- # {:role=>"user", :content=>"Draw a picture of a kitten"},
488
- # {:role=>"assistant", :content=>"Here is your picture of a kitten:", :images => ["./images/20250804T11303912_resp_abc123/001.png"], :response => #<Response ...>}
489
- # ]
548
+ # {
549
+ # :role => "user",
550
+ # :content => "Draw a picture of a kitten"
551
+ # },
552
+ # {
553
+ # :role => "assistant",
554
+ # :content => "Here is your picture of a kitten:",
555
+ # :images => [ "./images/20250804T11303912_resp_abc123/001.png" ],
556
+ # :response => { ... }
557
+ # }
558
+ # ]
490
559
  ```
491
560
 
492
561
  You can access the image filenames in several ways:
@@ -497,7 +566,7 @@ images = a.messages.last[:images]
497
566
  # => ["./images/20250804T11303912_resp_abc123/001.png"]
498
567
 
499
568
  # From the response object
500
- images = a.messages.last[:response].images
569
+ images = a.messages.last.dig(:response, :images)
501
570
  # => ["./images/20250804T11303912_resp_abc123/001.png"]
502
571
  ```
503
572
 
@@ -508,9 +577,11 @@ a = AI::Chat.new
508
577
  a.image_generation = true
509
578
  a.image_folder = "./images"
510
579
  a.user("Draw a picture of a kitten")
511
- a.generate! # => { :content => "Here is a picture of a kitten:", ... }
580
+ a.generate!
581
+ # => { :content => "Here is a picture of a kitten:", ... }
512
582
  a.user("Make it even cuter")
513
- a.generate! # => { :content => "Here is the kitten, but even cuter:", ... }
583
+ a.generate!
584
+ # => { :content => "Here is the kitten, but even cuter:", ... }
514
585
  ```
515
586
 
516
587
  ## Code Interpreter
@@ -519,7 +590,23 @@ a.generate! # => { :content => "Here is the kitten, but even cuter:", ... }
519
590
  y = AI::Chat.new
520
591
  y.code_interpreter = true
521
592
  y.user("Plot y = 2x*3 when x is -5 to 5.")
522
- y.generate! # => {:content => "Here is the graph.", ... }
593
+ y.generate!
594
+ # => { :content => "Here is the graph.", ... }
595
+ ```
596
+
597
+ ## Background mode
598
+
599
+ If you want to start a response and poll for it later, set `background = true` before calling `generate!`:
600
+
601
+ ```ruby
602
+ chat = AI::Chat.new
603
+ chat.background = true
604
+ chat.user("Write a short description about a sci-fi novel about a rat in space.")
605
+ chat.generate!
606
+
607
+ # Poll until it completes (this updates the existing assistant message)
608
+ message = chat.get_response(wait: true, timeout: 600)
609
+ puts message[:content]
523
610
  ```
524
611
 
525
612
  ## Proxying Through prepend.me
@@ -582,11 +669,11 @@ The `reasoning_effort` parameter guides the model on how many reasoning tokens t
582
669
  - `"medium"`: Balances speed and reasoning accuracy.
583
670
  - `"high"`: Favors more complete reasoning.
584
671
 
585
- By default, `reasoning_effort` is `nil`, which means no reasoning parameter is sent to the API. For `gpt-5.1` (the default model), this is equivalent to `"none"` reasoning.
672
+ By default, `reasoning_effort` is `nil`, which means no reasoning parameter is sent to the API. For `gpt-5.2` (the default model), this is equivalent to `"none"` reasoning.
586
673
 
587
674
  ## Advanced: Response Details
588
675
 
589
- When you call `generate!` or `generate!`, the gem stores additional information about the API response:
676
+ When you call `generate!` (or later call `get_response` in background mode), the gem stores additional information about the API response:
590
677
 
591
678
  ```ruby
592
679
  t = AI::Chat.new
@@ -594,18 +681,18 @@ t.user("Hello!")
594
681
  t.generate!
595
682
 
596
683
  # Each assistant message includes a response object
597
- pp t.messages.last
684
+ t.messages.last
598
685
  # => {
599
- # :role => "assistant",
600
- # :content => "Hello! How can I help you today?",
601
- # :response => { id=resp_abc... model=gpt-5.1 tokens=12 }
686
+ # :role => "assistant",
687
+ # :content => "Hello! How can I help you today?",
688
+ # :response => { id: "resp_abc...", model: "gpt-5.2", ... }
602
689
  # }
603
690
 
604
691
  # Access detailed information
605
692
  response = t.last[:response]
606
693
  response[:id] # => "resp_abc123..."
607
- response[:model] # => "gpt-5.1"
608
- response[:usage] # => {:prompt_tokens=>5, :completion_tokens=>7, :total_tokens=>12}
694
+ response[:model] # => "gpt-5.2"
695
+ response[:usage] # => {:input_tokens=>5, :output_tokens=>7, :total_tokens=>12}
609
696
  ```
610
697
 
611
698
  This information is useful for:
@@ -631,7 +718,18 @@ chat.generate!
631
718
  puts chat.last_response_id # => "resp_xyz789..." (a new ID)
632
719
  ```
633
720
 
634
- This is particularly useful for managing background tasks. When you make a request in background mode, you can immediately get the `last_response_id` to track, retrieve, or cancel that specific job later from a different process.
721
+ This is particularly useful for background mode workflows. If you want to retrieve or cancel a background response from a different process, use `OpenAI::Client` directly:
722
+
723
+ ```ruby
724
+ require "openai"
725
+
726
+ client = OpenAI::Client.new(api_key: ENV.fetch("OPENAI_API_KEY"))
727
+
728
+ response_id = "resp_abc123..." # e.g., load from your database
729
+ response = client.responses.retrieve(response_id)
730
+
731
+ client.responses.cancel(response_id) unless response.status.to_s == "completed"
732
+ ```
635
733
 
636
734
  ### Automatic Conversation Management
637
735
 
@@ -663,64 +761,117 @@ chat.generate! # Uses the loaded conversation
663
761
 
664
762
  ## Inspecting Conversation Details
665
763
 
666
- The gem provides two methods to inspect what happened during a conversation:
667
-
668
- ### `items` - Programmatic Access
669
-
670
- Returns the raw conversation items for programmatic use (displaying in views, filtering, etc.):
764
+ The `get_items` method fetches all conversation items (messages, tool calls, reasoning, etc.) from the API for both programmatic use and debugging:
671
765
 
672
766
  ```ruby
673
767
  chat = AI::Chat.new
768
+ chat.reasoning_effort = "high" # Enable reasoning summaries
674
769
  chat.web_search = true
675
770
  chat.user("Search for Ruby tutorials")
676
771
  chat.generate!
677
772
 
678
773
  # Get all conversation items (chronological order by default)
679
- page = chat.items
774
+ chat.get_items
680
775
 
681
- # Access item data
682
- page.data.each do |item|
776
+ # Output in IRB/Rails console:
777
+ # ┌────────────────────────────────────────────────────────────────────────────┐
778
+ # │ Conversation: conv_6903c1eea6cc819695af3a1b1ebf9b390c3db5e8ec021c9a │
779
+ # │ Items: 8 │
780
+ # └────────────────────────────────────────────────────────────────────────────┘
781
+ #
782
+ # [detailed colorized output of all items including web searches,
783
+ # reasoning summaries, tool calls, messages, etc.]
784
+
785
+ # Iterate over items programmatically
786
+ chat.get_items.data.each do |item|
683
787
  case item.type
684
788
  when :message
685
789
  puts "#{item.role}: #{item.content.first.text}"
686
790
  when :web_search_call
687
- puts "Web search: #{item.action.query}"
688
- puts "Results: #{item.results.length}"
791
+ puts "Web search: #{item.action.query}" if item.action.respond_to?(:query) && item.action.query
689
792
  when :reasoning
690
- puts "Reasoning: #{item.summary.first.text}"
793
+ # Reasoning summaries show a high-level view of the model's reasoning
794
+ if item.summary&.first
795
+ puts "Reasoning: #{item.summary.first.text}"
796
+ end
797
+ when :image_generation_call
798
+ puts "Image generated" if item.result
691
799
  end
692
800
  end
693
801
 
694
802
  # For long conversations, you can request reverse chronological order
695
803
  # (useful for pagination to get most recent items first)
696
- recent_items = chat.items(order: :desc)
804
+ recent_items = chat.get_items(order: :desc)
697
805
  ```
698
806
 
699
- ### `verbose` - Terminal Output
700
-
701
- Pretty-prints the entire conversation with all details for debugging and learning:
702
-
703
- ```ruby
704
- chat.verbose
705
-
706
- # Output:
707
- # ┌────────────────────────────────────────────────────────────────────────────┐
708
- # │ Conversation: conv_6903c1eea6cc819695af3a1b1ebf9b390c3db5e8ec021c9a │
709
- # │ Items: 3 │
710
- # └────────────────────────────────────────────────────────────────────────────┘
711
- #
712
- # [detailed colorized output of all items including web searches,
713
- # reasoning, tool calls, messages, etc.]
714
- ```
807
+ When `reasoning_effort` is set, the API returns reasoning summaries (e.g., "Planning Ruby version search", "Confirming image tool usage"). Note that not all reasoning items have summaries - some intermediate steps may be empty.
715
808
 
716
809
  This is useful for:
717
810
  - **Learning** how the model uses tools (web search, code interpreter, etc.)
718
811
  - **Debugging** why the model made certain decisions
719
812
  - **Understanding** the full context beyond just the final response
813
+ - **Transparency** into the model's reasoning process
814
+
815
+ ### HTML Output for ERB Templates
816
+
817
+ All display objects have a `to_html` method for rendering in ERB templates:
818
+
819
+ ```erb
820
+ <%# Display a chat object %>
821
+ <%= @chat.to_html %>
822
+
823
+ <%# Display individual messages %>
824
+ <% @chat.messages.each do |msg| %>
825
+ <%= msg.to_html %>
826
+ <% end %>
827
+
828
+ <%# Display conversation items (quick debug view) %>
829
+ <%= @chat.get_items.to_html %>
830
+ ```
831
+
832
+ The HTML output includes a dark background to match the terminal aesthetic.
833
+
834
+ You can also loop over `get_items.data` to build custom displays showing reasoning steps, tool calls, etc.:
835
+
836
+ ```erb
837
+ <% @chat.get_items.data.each do |item| %>
838
+ <% case item.type.to_s %>
839
+ <% when "message" %>
840
+ <div class="message <%= item.role %>">
841
+ <strong><%= item.role.capitalize %>:</strong>
842
+ <% if item.content&.first %>
843
+ <% content = item.content.first %>
844
+ <% if content.type.to_s == "input_text" %>
845
+ <%= content.text %>
846
+ <% elsif content.type.to_s == "output_text" %>
847
+ <%= content.text %>
848
+ <% end %>
849
+ <% end %>
850
+ </div>
851
+ <% when "reasoning" %>
852
+ <% if item.summary&.first %>
853
+ <details class="reasoning">
854
+ <summary>Reasoning</summary>
855
+ <%= item.summary.first.text %>
856
+ </details>
857
+ <% end %>
858
+ <% when "web_search_call" %>
859
+ <% if item.action.respond_to?(:query) && item.action.query %>
860
+ <div class="web-search">
861
+ Searched: "<%= item.action.query %>"
862
+ </div>
863
+ <% end %>
864
+ <% when "image_generation_call" %>
865
+ <div class="image-generation">
866
+ Image generated
867
+ </div>
868
+ <% end %>
869
+ <% end %>
870
+ ```
720
871
 
721
872
  ## Setting messages directly
722
873
 
723
- You can use `.messages=()` to assign an `Array` of `Hashes`. Each `Hash` must have keys `:role` and `:content`, and optionally `:image` or `:images`:
874
+ You can use `.messages=()` to assign an `Array` of `Hashes` (text-only). Each `Hash` must have keys `:role` and `:content`:
724
875
 
725
876
  ```ruby
726
877
  # Using the planet example with array of hashes
@@ -743,80 +894,8 @@ response = p.generate!
743
894
  puts response
744
895
  ```
745
896
 
746
- You can still include images:
747
-
748
- ```ruby
749
- # Create a new chat instance
750
- q = AI::Chat.new
751
-
752
- # With images
753
- q.messages = [
754
- { role: "system", content: "You are a helpful assistant." },
755
- { role: "user", content: "What's in this image?", image: "path/to/image.jpg" },
756
- ]
757
-
758
- # With multiple images
759
- q.messages = [
760
- { role: "system", content: "You are a helpful assistant." },
761
- { role: "user", content: "Compare these images", images: ["image1.jpg", "image2.jpg"] }
762
- ]
763
- ```
764
-
765
- ## Other Features Being Considered
766
-
767
- - **Streaming responses**: Real-time streaming as the AI generates its response
768
- - **Cost tracking**: Automatic calculation and tracking of API costs
769
- - **Token usage helpers**: Convenience methods like `total_tokens` to sum usage across all responses in a conversation
770
-
771
- ## TODO: Missing Test Coverage
772
-
773
- The following gem-specific logic would benefit from additional RSpec test coverage:
774
-
775
- 1. **Schema format normalization** - The `wrap_schema_if_needed` method detects and wraps 3 different input formats (raw, named, already-wrapped). This complex conditional logic could silently regress.
776
-
777
- 2. **Multimodal content array building** - The `add` method builds nested structures when images/files are provided, handling `image`/`images` and `file`/`files` parameters with specific ordering (text → images → files).
778
-
779
- 3. **File classification and processing** - `classify_obj` and `process_file_input` distinguish URLs vs file paths vs file-like objects, with MIME type detection determining encoding behavior.
780
-
781
- 4. **Message preparation after response** - `prepare_messages_for_api` has slicing logic that only sends messages after the last response, preventing re-sending entire conversation history.
782
-
783
- These are all gem-specific transformations (not just OpenAI pass-through) that could regress without proper test coverage.
784
-
785
- ## TODO: Code Quality
786
-
787
- Address Reek warnings (`bundle exec reek`). Currently 29 warnings for code smells like:
788
-
789
- - `TooManyStatements` in several methods
790
- - `DuplicateMethodCall` in `extract_and_save_files`, `verbose`, etc.
791
- - `RepeatedConditional` for `proxy` checks
792
- - `FeatureEnvy` in `parse_response` and `wait_for_response`
793
-
794
- These don't affect functionality but indicate areas for refactoring.
795
-
796
- Then, add `quality` back as a CI check.
797
-
798
- ## Testing with Real API Calls
799
-
800
- While this gem includes specs, they use mocked API responses. To test with real API calls:
801
-
802
- 1. Create a `.env` file at the project root with your API credentials:
803
- ```
804
- # Your OpenAI API key
805
- OPENAI_API_KEY=your_openai_api_key_here
806
- ```
807
- 2. Install dependencies: `bundle install`
808
- 3. Run the examples: `bundle exec ruby examples/all.rb`
809
-
810
- This test program runs through all the major features of the gem, making real API calls to OpenAI.
897
+ For images/files, prefer using `chat.user(..., image:/images:/file:/files:)` so the gem can build the correct multimodal structure.
811
898
 
812
899
  ## Contributing
813
900
 
814
- When contributing to this project:
815
-
816
- 1. **Code Style**: This project uses StandardRB for linting. Run `bundle exec standardrb --fix` before committing to automatically fix style issues.
817
-
818
- 2. **Testing**: Ensure all specs pass with `bundle exec rspec`.
819
-
820
- 3. **Examples**: If adding a feature, consider adding an example in the `examples/` directory.
821
-
822
- 4. **Documentation**: Update the README if your changes affect the public API.
901
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
data/ai-chat.gemspec CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = "ai-chat"
5
- spec.version = "0.4.0"
6
- spec.authors = ["Raghu Betina"]
7
- spec.email = ["raghu@firstdraft.com"]
5
+ spec.version = "0.5.0"
6
+ spec.authors = ["Raghu Betina", "Jelani Woods"]
7
+ spec.email = ["raghu@firstdraft.com", "jelani@firstdraft.com"]
8
8
  spec.homepage = "https://github.com/firstdraft/ai-chat"
9
9
  spec.summary = "A beginner-friendly Ruby interface for OpenAI's API"
10
10
  spec.license = "MIT"
@@ -1,4 +1,22 @@
1
1
  require "amazing_print"
2
+
3
+ # Fix AmazingPrint's colorless method to strip HTML tags in addition to ANSI codes.
4
+ # Without this, alignment is broken when html: true because colorless_size
5
+ # doesn't account for <kbd> tag lengths.
6
+ # TODO: Remove if https://github.com/amazing-print/amazing_print/pull/146 is merged.
7
+ module AmazingPrint
8
+ module Formatters
9
+ class BaseFormatter
10
+ alias_method :original_colorless, :colorless
11
+
12
+ def colorless(string)
13
+ result = original_colorless(string)
14
+ result.gsub(/<kbd[^>]*>|<\/kbd>/, "")
15
+ end
16
+ end
17
+ end
18
+ end
19
+
2
20
  # :reek:IrresponsibleModule
3
21
  module AmazingPrint
4
22
  module AI
@@ -27,33 +45,11 @@ module AmazingPrint
27
45
  end
28
46
  end
29
47
 
30
- # :reek:DuplicateMethodCall
31
48
  # :reek:FeatureEnvy
32
- # :reek:NilCheck
33
- # :reek:TooManyStatements
34
49
  def format_ai_chat(chat)
35
- vars = []
36
-
37
- # Format messages with truncation
38
- if chat.instance_variable_defined?(:@messages)
39
- messages = chat.instance_variable_get(:@messages).map do |msg|
40
- truncated_msg = msg.dup
41
- if msg[:content].is_a?(String) && msg[:content].length > 80
42
- truncated_msg[:content] = msg[:content][0..77] + "..."
43
- end
44
- truncated_msg
45
- end
46
- vars << ["@messages", messages]
50
+ vars = chat.inspectable_attributes.map do |(name, value)|
51
+ [name.to_s, value]
47
52
  end
48
-
49
- # Add other variables (except sensitive ones)
50
- skip_vars = [:@api_key, :@client, :@messages]
51
- chat.instance_variables.sort.each do |var|
52
- next if skip_vars.include?(var)
53
- value = chat.instance_variable_get(var)
54
- vars << [var.to_s, value] unless value.nil?
55
- end
56
-
57
53
  format_object(chat, vars)
58
54
  end
59
55
 
@@ -65,10 +61,13 @@ module AmazingPrint
65
61
  "#{name}: #{inspector.awesome(value)}"
66
62
  end
67
63
 
64
+ lt = @options[:html] ? "&lt;" : "<"
65
+ gt = @options[:html] ? "&gt;" : ">"
66
+
68
67
  if @options[:multiline]
69
- "#<#{object.class}\n#{data.map { |line| " #{line}" }.join("\n")}\n>"
68
+ "##{lt}#{object.class}\n#{data.map { |line| " #{line}" }.join("\n")}\n#{gt}"
70
69
  else
71
- "#<#{object.class} #{data.join(", ")}>"
70
+ "##{lt}#{object.class} #{data.join(", ")}#{gt}"
72
71
  end
73
72
  end
74
73
  end
data/lib/ai/chat.rb CHANGED
@@ -12,7 +12,6 @@ require "tty-spinner"
12
12
  require "timeout"
13
13
 
14
14
  require_relative "http"
15
- include AI::Http
16
15
 
17
16
  module AI
18
17
  # :reek:MissingSafeMethod { exclude: [ generate! ] }
@@ -21,6 +20,8 @@ module AI
21
20
  # :reek:InstanceVariableAssumption
22
21
  # :reek:IrresponsibleModule
23
22
  class Chat
23
+ include AI::Http
24
+
24
25
  # :reek:Attribute
25
26
  attr_accessor :background, :code_interpreter, :conversation_id, :image_generation, :image_folder, :messages, :model, :proxy, :reasoning_effort, :web_search
26
27
  attr_reader :client, :last_response_id, :schema, :schema_file
@@ -31,7 +32,7 @@ module AI
31
32
  @api_key = api_key || ENV.fetch(api_key_env_var)
32
33
  @messages = []
33
34
  @reasoning_effort = nil
34
- @model = "gpt-5.1"
35
+ @model = "gpt-5.2"
35
36
  @client = OpenAI::Client.new(api_key: @api_key)
36
37
  @last_response_id = nil
37
38
  @proxy = false
@@ -47,7 +48,7 @@ module AI
47
48
  json = if proxy
48
49
  uri = URI(PROXY_URL + "api.openai.com/v1/responses")
49
50
  parameters = {
50
- model: "gpt-5.1",
51
+ model: "gpt-5.2",
51
52
  input: [
52
53
  {role: :system, content: system_prompt},
53
54
  {role: :user, content: description}
@@ -60,7 +61,7 @@ module AI
60
61
  else
61
62
  client = OpenAI::Client.new(api_key: api_key)
62
63
  response = client.responses.create(
63
- model: "gpt-5.1",
64
+ model: "gpt-5.2",
64
65
  input: [
65
66
  {role: :system, content: system_prompt},
66
67
  {role: :user, content: description}
@@ -84,15 +85,12 @@ module AI
84
85
  # :reek:TooManyStatements
85
86
  # :reek:NilCheck
86
87
  def add(content, role: "user", response: nil, status: nil, image: nil, images: nil, file: nil, files: nil)
87
- if image.nil? && images.nil? && file.nil? && files.nil?
88
- message = {
89
- role: role,
90
- content: content,
91
- response: response
92
- }
93
- message[:content] = content if content
94
- message[:status] = status if status
95
- messages.push(message)
88
+ message = if image.nil? && images.nil? && file.nil? && files.nil?
89
+ msg = Message[role: role]
90
+ msg[:content] = content if content
91
+ msg[:response] = response if response
92
+ msg[:status] = status if status
93
+ msg
96
94
  else
97
95
  text_and_files_array = [
98
96
  {
@@ -122,14 +120,15 @@ module AI
122
120
  text_and_files_array.push(process_file_input(file))
123
121
  end
124
122
 
125
- messages.push(
126
- {
127
- role: role,
128
- content: text_and_files_array,
129
- status: status
130
- }
131
- )
123
+ Message[
124
+ role: role,
125
+ content: text_and_files_array,
126
+ status: status
127
+ ]
132
128
  end
129
+
130
+ messages.push(message)
131
+ message
133
132
  end
134
133
 
135
134
  def system(message)
@@ -189,10 +188,10 @@ module AI
189
188
  messages.last
190
189
  end
191
190
 
192
- def items(order: :asc)
191
+ def get_items(order: :asc)
193
192
  raise "No conversation_id set. Call generate! first to create a conversation." unless conversation_id
194
193
 
195
- if proxy
194
+ raw_items = if proxy
196
195
  uri = URI(PROXY_URL + "api.openai.com/v1/conversations/#{conversation_id}/items?order=#{order}")
197
196
  response_hash = send_request(uri, content_type: "json", method: "get")
198
197
 
@@ -215,62 +214,50 @@ module AI
215
214
  else
216
215
  client.conversations.items.list(conversation_id, order: order)
217
216
  end
217
+
218
+ Items.new(raw_items, conversation_id: conversation_id)
218
219
  end
219
220
 
220
- def verbose
221
- page = items
221
+ def inspectable_attributes
222
+ attrs = []
223
+
224
+ # 1. Model and reasoning (configuration)
225
+ attrs << [:@model, @model]
226
+ attrs << [:@reasoning_effort, @reasoning_effort]
227
+
228
+ # 2. Conversation state
229
+ attrs << [:@conversation_id, @conversation_id]
230
+ attrs << [:@last_response_id, @last_response_id] if @last_response_id
222
231
 
223
- box_width = 78
224
- inner_width = box_width - 4
232
+ # 3. Messages (the main content, without response details)
233
+ display_messages = @messages.map { |msg| msg.except(:response) }
234
+ attrs << [:@messages, display_messages]
225
235
 
226
- puts
227
- puts "┌#{"─" * (box_width - 2)}┐"
228
- puts "│ Conversation: #{conversation_id.ljust(inner_width - 14)} │"
229
- puts "│ Items: #{page.data.length.to_s.ljust(inner_width - 7)} "
230
- puts "└#{"─" * (box_width - 2)}┘"
231
- puts
236
+ # 4. Optional features (only if enabled/changed from default)
237
+ attrs << [:@proxy, @proxy] if @proxy != false
238
+ attrs << [:@image_generation, @image_generation] if @image_generation != false
239
+ attrs << [:@image_folder, @image_folder] if @image_folder != "./images"
232
240
 
233
- ap page.data, limit: 10, indent: 2
241
+ # 5. Optional state (only if set)
242
+ attrs << [:@background, @background] if @background
243
+ attrs << [:@code_interpreter, @code_interpreter] if @code_interpreter
244
+ attrs << [:@web_search, @web_search] if @web_search
245
+ attrs << [:@schema, @schema] if @schema
246
+ attrs << [:@schema_file, @schema_file] if @schema_file
247
+
248
+ attrs
234
249
  end
235
250
 
236
251
  def inspect
237
- "#<#{self.class.name} @messages=#{messages.inspect} @model=#{@model.inspect} @schema=#{@schema.inspect} @reasoning_effort=#{@reasoning_effort.inspect}>"
252
+ ai(plain: !$stdout.tty?, multiline: true)
238
253
  end
239
254
 
240
- # Support for Ruby's pp (pretty print)
241
- # :reek:TooManyStatements
242
- # :reek:NilCheck
243
- # :reek:FeatureEnvy
244
- # :reek:DuplicateMethodCall
245
- # :reek:UncommunicativeParameterName
246
- def pretty_print(q)
247
- q.group(1, "#<#{self.class}", ">") do
248
- q.breakable
249
-
250
- # Show messages with truncation
251
- q.text "@messages="
252
- truncated_messages = @messages.map do |msg|
253
- truncated_msg = msg.dup
254
- if msg[:content].is_a?(String) && msg[:content].length > 80
255
- truncated_msg[:content] = msg[:content][0..77] + "..."
256
- end
257
- truncated_msg
258
- end
259
- q.pp truncated_messages
260
-
261
- # Show other instance variables (except sensitive ones)
262
- skip_vars = [:@messages, :@api_key, :@client]
263
- instance_variables.sort.each do |var|
264
- next if skip_vars.include?(var)
265
- value = instance_variable_get(var)
266
- unless value.nil?
267
- q.text ","
268
- q.breakable
269
- q.text "#{var}="
270
- q.pp value
271
- end
272
- end
273
- end
255
+ def to_html
256
+ AI.wrap_html(ai(html: true, multiline: true))
257
+ end
258
+
259
+ def pretty_inspect
260
+ "#{inspect}\n"
274
261
  end
275
262
 
276
263
  private
@@ -312,7 +299,7 @@ module AI
312
299
  parameters[:background] = background if background
313
300
  parameters[:tools] = tools unless tools.empty?
314
301
  parameters[:text] = schema if schema
315
- parameters[:reasoning] = {effort: reasoning_effort} if reasoning_effort
302
+ parameters[:reasoning] = {effort: reasoning_effort, summary: "auto"} if reasoning_effort
316
303
 
317
304
  create_conversation unless conversation_id
318
305
  parameters[:conversation] = conversation_id
@@ -387,12 +374,12 @@ module AI
387
374
  message.dig(:response, :id) == response_id
388
375
  end
389
376
 
390
- message = {
377
+ message = Message[
391
378
  role: "assistant",
392
379
  content: response_content,
393
380
  response: chat_response,
394
381
  status: response_status
395
- }
382
+ ]
396
383
 
397
384
  message.store(:images, image_filenames) unless image_filenames.empty?
398
385
 
@@ -400,8 +387,9 @@ module AI
400
387
  messages[existing_message_position] = message
401
388
  else
402
389
  messages.push(message)
403
- message
404
390
  end
391
+
392
+ message
405
393
  end
406
394
 
407
395
  def cancel_request
data/lib/ai/items.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module AI
6
+ class Items < SimpleDelegator
7
+ def initialize(response, conversation_id:)
8
+ super(response)
9
+ @conversation_id = conversation_id
10
+ end
11
+
12
+ def to_html
13
+ AI.wrap_html(build_output(html: true))
14
+ end
15
+
16
+ def inspect
17
+ build_output(html: false, plain: !$stdout.tty?)
18
+ end
19
+
20
+ def pretty_inspect
21
+ "#{inspect}\n"
22
+ end
23
+
24
+ def pretty_print(q)
25
+ q.output << inspect
26
+ end
27
+
28
+ private
29
+
30
+ def build_output(html: false, plain: false)
31
+ box = build_box
32
+ items_output = data.ai(html: html, plain: plain, limit: 100, indent: 2, index: true)
33
+
34
+ if html
35
+ "<pre>#{box}</pre>\n#{items_output}"
36
+ else
37
+ "#{box}\n#{items_output}"
38
+ end
39
+ end
40
+
41
+ def build_box
42
+ box_width = 78
43
+ inner_width = box_width - 4
44
+
45
+ lines = []
46
+ lines << "┌#{"─" * (box_width - 2)}┐"
47
+ lines << "│ Conversation: #{@conversation_id.to_s.ljust(inner_width - 14)} │"
48
+ lines << "│ Items: #{data.length.to_s.ljust(inner_width - 7)} │"
49
+ lines << "└#{"─" * (box_width - 2)}┘"
50
+
51
+ lines.join("\n")
52
+ end
53
+ end
54
+ end
data/lib/ai/message.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AI
4
+ class Message < Hash
5
+ def inspect
6
+ ai(plain: !$stdout.tty?, index: false)
7
+ end
8
+
9
+ def pretty_inspect
10
+ "#{inspect}\n"
11
+ end
12
+
13
+ # IRB's ColorPrinter calls pretty_print and re-colorizes text,
14
+ # which escapes our ANSI codes. Write directly to output to bypass.
15
+ def pretty_print(q)
16
+ q.output << inspect
17
+ end
18
+
19
+ def to_html
20
+ AI.wrap_html(ai(html: true, index: false))
21
+ end
22
+ end
23
+ end
data/lib/ai-chat.rb CHANGED
@@ -1,3 +1,14 @@
1
+ module AI
2
+ HTML_PRE_STYLE = "background-color: #1e1e1e; color: #d4d4d4; padding: 1em; white-space: pre-wrap; word-wrap: break-word;"
3
+
4
+ def self.wrap_html(html)
5
+ html = html.gsub("<pre>", "<pre style=\"#{HTML_PRE_STYLE}\">")
6
+ html.respond_to?(:html_safe) ? html.html_safe : html
7
+ end
8
+ end
9
+
10
+ require_relative "ai/message"
11
+ require_relative "ai/items"
1
12
  require_relative "ai/chat"
2
13
 
3
14
  # Load amazing_print extension if amazing_print is available
metadata CHANGED
@@ -1,10 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai-chat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raghu Betina
8
+ - Jelani Woods
8
9
  bindir: bin
9
10
  cert_chain: []
10
11
  date: 1980-01-02 00:00:00.000000000 Z
@@ -143,6 +144,7 @@ dependencies:
143
144
  version: '11.1'
144
145
  email:
145
146
  - raghu@firstdraft.com
147
+ - jelani@firstdraft.com
146
148
  executables: []
147
149
  extensions: []
148
150
  extra_rdoc_files:
@@ -156,6 +158,8 @@ files:
156
158
  - lib/ai/amazing_print.rb
157
159
  - lib/ai/chat.rb
158
160
  - lib/ai/http.rb
161
+ - lib/ai/items.rb
162
+ - lib/ai/message.rb
159
163
  - lib/prompts/schema_generator.md
160
164
  homepage: https://github.com/firstdraft/ai-chat
161
165
  licenses:
@@ -181,7 +185,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
181
185
  - !ruby/object:Gem::Version
182
186
  version: '0'
183
187
  requirements: []
184
- rubygems_version: 3.7.1
188
+ rubygems_version: 4.0.1
185
189
  specification_version: 4
186
190
  summary: A beginner-friendly Ruby interface for OpenAI's API
187
191
  test_files: []