Code Less and Ship faster with OpenAI
This is the talk I gave at Saigon.rb on March 21, 2023.
Download the slides (pdf) | Present the slides
how AI makes me better
Three ways Large language models makes me better are by filling in the gaps where I :
- Am not good at,
- Have no time for,
- Am Lazy or forgetful about.
In this article, we will see how I can use AI to fill in these gaps and ship code faster.
1. Frontend work - helped by AI
I am generally not great at frontend work. Most of my career I’ve specialized in Backend, sometimes Management things.
This year I built a small internal app, a rental equipment managment application. And while it does the job, it is not pretty.
I asked chatGPT to make few frontend changes.
This was the prompt I wrote:
I have this ViewComponent in Rails and I use stimulus. I want to hide all this code behind a box that looks like a dropdown box. If any tag is included (selected_values contains one), turn the select box green. Else gray. Only use stimulus and tailwind and Rails/ERB
%= helpers.turbo_frame_tag "tag_filter" do% ... (all my ERB code)
As you can see below, this was a cake walk for it.
I also added couple more tweaks:
make the dropdown button look more like a dropdown menu with an upside / downside angle icon
rotate the angle up then the dropdown is expanded
As you can see from the Image above, the code it provided created an upside angle icon for both dropdown buttons. it also made sure that the arrows are upside when the dropdown is unexpanded, and down, when selected.
Make it look pretty!
In the Previous prompts, i gave ChatGPT detailed specification of what I wanted. But how well can it understand vague questions?
This is a form in the app where they can add or remove a certain quantity from their stock and I simply prompted this:
This form looks ugly. Please make it pretty! [code here]
It understood which section requires correction for the form to apear “pretty” and make the changes. here is the new version:
2. If you don’t have time for testing=
I wanted to see if it can do integration tests. It took me a couple of tries before arriving at a prompt that works best.
What did not work
First try was to just throw all the code in to GPT4 and ask it to write me an integration test.
Throw it all in!
rake prompts:copy folder_path=app/models | pbcopy
Write me an integration test for this Rails data model:
Ruby file: /app/models/api_key.rb class ApiKey < ApplicationRecord include Discard::Model ... Ruby file: /app/models/user.rb ... (+contents) Ruby file: /app/models/event.rb ... ... (+contents)
This was the response I got:
require "rails_helper"
RSpec.describe "Integration Test" do
let(:user) { create(:user, email: "[email protected]") }
let(:api_key) { ApiKey.generate(user) }
let(:place) { create(:place, name: "Test Place") }
let(:resource) { create(:resource, resourceable: place) }
let(:person) { create(:person, first_name: "John", last_name: "Doe") }
let(:start_time) { Time.zone.now }
let(:end_time) { 1.hour.from_now }
let(:event) { create(:event, title: "Test Event", start_time: start_time, end_time: end_time, resource: resource) }
let(:participation) { create(:participation, event: event, resource: resource) }
it "creates and associates all models correctly" do
user
api_key
place
resource
person
event
participation
expect(user.email).to eq("[email protected]")
expect(api_key.token).to be_present
expect(api_key.user).to eq(user)
expect(place.name).to eq("Test Place")
expect(place.resource).to eq(resource)
expect(resource.resourceable).to eq(place)
expect(person.first_name).to eq("John")
expect(person.last_name).to eq("Doe")
expect(event.title).to eq("Test Event")
expect(event.start_time).to eq(start_time)
expect(event.end_time).to eq(end_time)
expect(event.resource).to eq(resource)
expect(participation.event).to eq(event)
expect(participation.resource).to eq(resource)
end
end
- This, unfortunately, is not an integration test.
However, the next method worked well.
Give me some ideas for integration tests that test the collaboration of these models.
...
Test stock changes.
Test creating stock changes with different kinds
(e.g., add, remove).
Test validation for presence and numericality of
quantity and initial_quantity.
...
Take #9 and code it in rspec.
- Here is the response:
2b. Refactoring
- The Question:
I have 2 Rails ViewComponents, which act as filters in the view. If they are active (set via hotwire into the session) they filter the current query.
How can I refactor this to build reusable filter components and not clutter my controller? Here’s the controller:
def index
@filters = session[SESS] || {}
@filters.compact!
@filters["tags"] ||= {}
@tags = Gutentag::Tag.names_for_scope(Item)
@items = policy_scope(Item).includes(:place)
@tag_counts = @tags.index_with { |tag| Item.tagged_with(names: tag).count }
@items = @items.where("name ilike ?", "%#{params[:name]}%") if params[:name].present?
# coming from Filters::TagFilterComponent.new(tags: @tags, filters: @filters["tags"], counts: @tag_counts) %>
# coming from Filters::QuantityFilterComponent.new(filters: @filters["quantity"]) %>
if session.dig(SESS, "tags")&.size&.positive?
@items = @items.tagged_with(names: session.dig(SESS, "tags"), match: :any)
end
if session.dig(SESS, "quantity", "from")
@items = @items.where("quantity >= ?", session.dig(SESS, "quantity", "from"))
end
@items = @items.where("quantity <= ?", session.dig(SESS, "quantity", "to")) if session.dig(SESS, "quantity", "to")
@pagy, @items = pagy(@items.load_async)
end
- The Result:
To refactor the filter components and reduce the clutter in the controller, you can consider the following steps:
- Create a base filter component that accepts filter data and applies the filters to the query. This will be the reusable component that can be extended for each specific filter.
- Extend the base filter component for each specific filter (e.g., tag filter, quantity filter). Each extended filter component should define its own filter fields and how they should be applied to the query.
- Use these filter components in your view instead of the current tag and quantity filters.
class Filters::BaseComponent < ViewComponent::Base
attr_reader :filters
def initialize(filters:) ...
def apply_filter(query) ...
end
class Filters::TagFilterComponent < Filters::BaseComponent
attr_reader :tags, :counts
def initialize(tags:, counts:, filters:)
super(filters: filters)
@tags = tags
@counts = counts
end
def apply_filter(query)
query.tagged_with(names: filters, match: :any)
end
end
class Filters::QuantityFilterComponent < Filters::BaseComponent
def apply_filter(query)
if filters["from"]
query.where("quantity >= ?", filters["from"])
end
if filters["to"]
query.where("quantity <= ?", filters["to"])
end
end
end
- Not a bad idea: just use the ViewComponent in the Controller.
def index
@filters = session[SESS] || {}
@filters.compact!
@filters["tags"] ||= {}
@items = policy_scope(Item).includes(:place)
@items = Filters::NameFilterComponent.new(name: params[:name]).apply_filter(@items)
@items = Filters::TagFilterComponent.new(tags: @tags, counts: @tag_counts, filters: session.dig(SESS, "tags")).apply_filter(@items)
@items = Filters::QuantityFilterComponent.new(filters: session.dig(SESS, "quantity")).apply_filter(@items)
@pagy, @items = pagy(@items.load_async)
end
Not happy yet
I wasn’t fully satisfied with the response so I kept on asking.
- Query:
Please refactor further: I want to be able to place filters in the session > and it only instantiates the filter I really need in the controller.
- response:
class Filters::TagFilterComponent < Filters::BaseComponent
def applicable?
session_filters&.dig("tags")&.any?
end
end
and in the controller
filter_components = [
Filters::TagFilterComponent.new(tags: @tags, counts: @tag_counts, filters: @session_filters.dig("tags"), session_filters: @session_filters),
Filters::QuantityFilterComponent.new(filters: @session_filters.dig("quantity"), session_filters: @session_filters)
# Add more filters as needed
]
filter_components.select(&:applicable?).each do |filter_component|
@items = filter_component.apply_filter(@items)
end
What’s that?
You invented
Filters::NameFilterComponent
– show me the code please.
class Filters::NameFilterComponent < Filters::BaseComponent
attr_reader :name
def apply_filter(query)
if name.present?
query.where("name ilike ?", "%#{name}%")
else
query
end
end
end
Pushing it further
- question:
your array filter_components still instantiates the component. Is there an easier way to get the component based on what settings are present in the session?
- Response:
# In your controller
def index
# Map session keys to filter component classes
filter_components = {
"name" => Filters::NameFilterComponent,
"tags" => Filters::TagFilterComponent,
"quantity" => Filters::QuantityFilterComponent
}
# Instantiate the applicable filter components
active_filter_components = session[SESS].to_h.select { |key, _| filter_components.include?(key) }.map do |key, value|
filter_components[key].new(**value.symbolize_keys)
end
# Apply the filters to the query
query = policy_scope(Item).includes(:place)
active_filter_components.each do |filter|
query = filter.apply_filter(query)
end
# Other controller logic ...
end
Now we are getting there!
3a. Code Lazily
Github Copilot has gotten a lot of a accolade lately in assisting programmers by auto completeing their codes.
I tried to test github copilot while developing the rental system. This is what I have noticed:
Copilot works great for contained methods and algorithms. Not great for structures.
RSpec.describe “Rental system”, type: :integration do
3b. When I forget the API
Prompt given:
write me the relations in the model StockChange corresponding to this:
> class Rental < ApplicationRecord
belongs_to :item
belongs_to :renter, class_name: 'Person', foreign_key: 'renter_id'
belongs_to :checkout, class_name: 'StockChange', foreign_key: 'checkout_id'
belongs_to :return, class_name: 'StockChange', foreign_key: 'return_id', optional: true
The result was better than I expected:
class StockChange < ApplicationRecord
belongs_to :item
belongs_to :user
# Add these lines
has_one :checkout_rental, class_name: 'Rental', foreign_key: 'checkout_id', dependent: :restrict_with_exception
has_one :return_rental, class_name: 'Rental', foreign_key: 'return_id', dependent: :restrict_with_exception
end
Testing it for more applications
Using it as a general API
I had high hopes for GPT-4
This is a comparison between GPT-3.5 and GPT-4 in splitting a vietnamese sentence into words.
Question:
Split this into words: Cô ấy cảm thấy buồn ngủ.
Response:
There’s still some hope
My first thought was perhaps tokenization is not a strong suite of GPT-4
After playing around a bit, I figured out how to generate correct response from it.
- The following prompt did the trick:
Split this into Vietnamese words that make sense. For every correct word use the format
Great for creative writing
Extra: Do stuff I haven’t done before
Wrap this ruby code in some structure so that a timeout will still result in the creation of a GptQuery entry
private def generate_openai_response
start = Time.zone.now
response = client.chat(
parameters: {
model: "gpt-4",
messages: [{ role: "user", content: @prompt}],
max_tokens: 300
}
)
elapsed = Time.zone.now - start
query = GptQuery.create!(response:, response_time: elapsed, prompt: @prompt)
query.update(tokens: response["usage"]["total_tokens"]) if response["usage"]
query
end
results:
begin
Timeout::timeout(10) do # set the desired timeout duration in seconds
response = client.chat(
parameters: {
model: "gpt-4",
messages: [{ role: "user", content: @prompt }],
max_tokens: 300
}
)
end
rescue Timeout::Error
error_occurred = true
end
Conclusion
- AI is not “Intelligent”. It only knows how to figure out the next word, based on a lot of inputs.
- You won’t get novel ideas. But you get over the boring stuff, fast.
- Think: What can you build with it?