Implementing AI Copywriting with ChatGPT in Elixir

Implementing AI Copywriting with ChatGPT in Elixir

Intoduction

Building on our previous exploration of creating a crypto news website using AI-generated content, we are now turning our focus towards the application of that parsed data. The goal of this next phase is to leverage the capabilities of OpenAI’s language model, ChatGPT, to copywrite the parsed information.

Previous article:

An Experiment in Creating a Crypto News Website

In this article, we will delve into the technicalities of how we can make this happen, using the versatile Elixir library, Tesla, to create a client module for making HTTP requests. Our client module will consist of two main methods. The first method aims to rewrite paragraph text with a specific prompt, effectively transforming the original information into an AI-generated version. The second method is designed to generate a copywritten description from the first paragraph of each parsed post.

CopywriteAPI module

The beauty of working with the Elixir Tesla library is that it simplifies the process of creating HTTP requests, making the implementation of our AI copywriting module far from complex. With a solid understanding of Elixir and the Tesla library, you’ll find that building the copywriter api module is a relatively straightforward process. So, without further ado, let’s dive into the code!

defmodule Pandora.CopywriterAPI do
  use Tesla

  require Logger

  @api_token Application.compile_env!(:pandora, [Pandora.CopywriterAPI, :api_key])

  plug Tesla.Middleware.BaseUrl, "https://api.openai.com"
  plug Tesla.Middleware.Headers, [{"authorization", "Bearer #{@api_token}"}]
  plug Tesla.Middleware.JSON

  @rewrite_text_task "REDACTED"
  @doc """
  Rewrites text with given prompt.
  https://platform.openai.com/docs/api-reference/completions
  """
  def rewrite(text), do: do_request(text, @rewrite_text_task)

  @description_task "REDACTED"
  def create_description({:p, paragraph}), do: do_request(paragraph, @description_task)

  defp do_request(text, prompt) do
    tokens = ((String.length(text) / 4) |> ceil()) + 100
    payload = %{model: "text-davinci-003", prompt: [prompt, text], max_tokens: tokens, temperature: 0.7}

    with {:ok, %{body: %{"id" => _} = body}} <- post("/v1/completions", payload) do
      body["choices"]
      |> Enum.find(fn choice -> choice["index"] == 1 end)
      |> Map.get("text")
      |> String.trim()
    else
      error ->
        Logger.error("Can't create description. Error: #{inspect(error)}")
        error
    end
  end
end

Copywriter module

In this next phase of our journey, we delve into the implementation of the Copywriter GenServer process. This is where the magic really starts to happen.

Upon initialization, this process fetches a batch of parsed posts from the database, setting a limit to control the workload. It then meticulously processes these posts one by one, ensuring careful and accurate copywriting. After every post is processed, the GenServer sets a timer for a specified interval before proceeding to the next chunk, thereby controlling the pace of the operation.

The parsed posts are stored in the database, and their content is represented as a list of tuples, each containing an HTML tag and its corresponding binary content. Copywriter takes each post and reduces the content, copywriting each paragraph by calling upon our previously discussed CopywriterAPI.

But it doesn’t stop there. It also uses the CopywriterAPI to generate a captivating description from the first paragraph of the post. This description plays a pivotal role in capturing the attention of potential readers on the website.

Once the copywriting is done, the GenServer creates a new post in the database, ready to be displayed on the website. To increase the reach, it also publishes a message with this post in a Telegram channel, ensuring that our content reaches as many interested readers as possible. Lastly, it refreshes the sitemap.xml file, keeping our website up-to-date for web crawlers and consequently, improving our SEO.

Through the combination of Elixir, the Tesla library, and the Copywriter GenServer process, we have a well-oiled machine that transforms parsed data into SEO-friendly, AI-generated content. The possibilities are, indeed, exciting!

defmodule Pandora.Copywriter do
  use GenServer

  require Logger

  alias Pandora.Post

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl true
  def init(opts) do
    run_interval = Keyword.fetch!(opts, :run_interval)
    limit = Keyword.fetch!(opts, :limit)
    {:ok, %{limit: limit, run_interval: run_interval}, {:continue, :first_run}}
  end

  @impl true
  def handle_continue(:first_run, %{limit: limit} = state) do
    Logger.info("Starting copywriter and executing first run")
    process_parsed_posts(limit)
    {:noreply, state, {:continue, :schedule_next_run}}
  end

  def handle_continue(:schedule_next_run, %{run_interval: run_interval} = state) do
    Process.send_after(self(), :process_parsed_posts, run_interval)
    {:noreply, state}
  end

  @impl true
  def handle_info(:process_parsed_posts, %{limit: limit} = state) do
    process_parsed_posts(limit)
    {:noreply, state, {:continue, :schedule_next_run}}
  end

  defp process_parsed_posts(limit) do
    limit
    |> Pandora.ParsedPost.load_unprocessed_posts()
    |> Enum.each(fn parsed_post ->
      content =
        Enum.reduce(parsed_post.content, [], fn
          {:p, chunk}, acc ->
            case Pandora.CopywriterAPI.rewrite(chunk) do
              text when is_binary(text) -> [{:p, text} | acc]
              _ -> [{:p, chunk} | acc]
            end

          el, acc ->
            [el | acc]
        end)
        |> Enum.reverse()

      first_paragraph = Enum.find(content, fn {:p, _} -> true end)

      description =
        if first_paragraph do
          Pandora.CopywriterAPI.create_description(first_paragraph)
        end

      attrs = %{
        title: parsed_post.title,
        content: content,
        description: description,
        datetime: parsed_post.datetime
      }

      case Post.insert(attrs) do
        {:ok, post} ->
            send_to_telegram(post)
            Pandora.Sitemaps.generate()

          Logger.info("Inserted new post with ID #{post.id}")

        {:error, changeset} ->
          Logger.error("Post insertion failed. Errors: #{inspect(changeset.errors)}")
      end

      Pandora.ParsedPost.mark_processed(parsed_post)
    end)
  end

  @bot_token "REDACTED"
  @chat_id "REDACTED"
  def send_to_telegram(post) do
    {_, first_paragraph} = Enum.find(post.content, fn {el, _content} -> el == :p end)

    formatted_title = format(post.title)

    url = PandoraWeb.Router.Helpers.post_url(PandoraWeb.Endpoint, :show, post.slug)

    result =
      Telegram.Api.request(@bot_token, "sendMessage", %{
        chat_id: @chat_id,
        text: "\*#{formatted_title}\*
#{format(first_paragraph)}
\[Read more\]\(#{format(url)}/\)",
        # \[Read more\]\(#{url}\)",
        parse_mode: "MarkdownV2"
      })

    case result do
      {:ok, _} -> :ok
      {:error, error} -> Logger.error("Telegram API error: #{inspect(error)}")
    end
  end

  defp format(string) do
    String.replace(
      string,
      ["_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"],
      fn symb -> "\\" <> symb end
    )
  end
end

Some numbers

We’ve successfully published over 500 posts, all curated and copywritten by our AI. Maintaining affordability with copywriting costs around $60. In terms of search engine visibility: 77 pages indexed by Google and 66 by Bing.

In conclusion, leveraging the power of Elixir, the Tesla library, and ChatGPT, we’ve implemented modules for transforming parsed data into AI-generated content for our crypto news website. Implementing the CopywriterAPI and the Copywriter process has demonstrated how AI copywriting can be a streamlined, efficient process with immense potential.

Stay tuned.