Code Less and Ship faster with OpenAI

19 min read

This is the talk I gave at Saigon.rb on March 21, 2023.

Play: Video

thumbnail

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 :

  1. Am not good at,
  2. Have no time for,
  3. 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.

Anyschedule

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.

Filter2

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

filter1

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?

ugly

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:

image

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!

width:500px

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:

image

2b. Refactoring

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

width:600px

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

image

image

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

image

image

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:

image

There’s still some hope

My first thought was perhaps tokenization is not a strong suite of GPT-4

image

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 {"vi": <word>, "en": <English translation>} : " Tôi có một ..."

width:500px

Great for creative writing

language_app

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?

Till Carlos

I'm Till, a senior developer who started a software company. I explain software concepts for people in leading roles.