マイペースなRailsおじさん

Ruby、Ruby on Rails、オブジェクト指向設計を主なテーマとして扱います。だんだん大きくなっていくRuby on Rails製プロダクトのメンテナンス性を損なわない方法を考えたり考えなかったりしている人のブログです。

AIに最近のエンジニアブログについてツイートさせてみた

この記事は SmartHR Advent Calendar 2023 2nd の13日目の記事です。

エンジニアブログについてのツイート

最近、Xで定期的にこんなツイートをしています。

これはOpenAI APIを使って自動生成したツイートです。 もともと自分でブログを読んで備忘用にツイートをしていたのですが、下記のような背景から自動化してみることにしました。

  • OpenAI APIがとても流行っていて、そう遠くないうちに仕事などで使うことになりそうなので何か作ってみたかった
  • いい記事を見つけてもツイートをする気力が無くて断念してしまうことがあった
  • AIに自動でツイートさせて、良くないものは事後チェックして消す運用をすれば、今までとそこまで変わらない備忘録が作れるのではないかと考えた

構成

image.png ツイートは2つのCloud Function関数とDatastoreモードのFirestoreを使って実装しています。 ツイートするまでの流れは下記のとおりです。

  • ①記事保存用の関数で、企業テックブログRSSから記事のタイトル、概要、URLを取得します。
  • ②取得した記事の情報をFirestoreに保存します。
  • ③ツイート用の関数で、Firestoreから記事情報を取得します。
  • ④OpenAI APIに記事情報付きのプロンプトを与え、ツイートを生成します。
  • ⑤取得したツイートをXに保存します。

同じ記事についてのツイートを複数回してしまう可能性があるので、下記の動作によって防止しています。

  • ⑤のあとに、ツイートの対象となった記事のデータに、ツイート済みであることを示すプロパティを付与します。
  • ③の記事取得時に、ツイート済みの記事は取得しないようにします。
  • ②で、すでに取得済みの記事は再度取り込まないようにします。

Cloude Schedulerを用いて、記事保存用の関数を1時間ごと、ツイート用の関数を日中に5時間ごとに実行しています。

記事保存用関数

関数はRubyで書いています。 RSSから必要な情報を取得してDatastore(DatastoreモードのFirestore)に保存します。 postedプロパティは、記事がツイート済みであることを表すプロパティで、取り込み時はまだツイートしていないのでfalseにします。

ソースコード

require "functions_framework"
require "google/cloud/datastore"
require "rss"
require "open-uri"

FunctionsFramework.http "aggregate" do |request|
  datastore = Google::Cloud::Datastore.new project_id: project_id
  
  url = 'https://yamadashy.github.io/tech-blog-rss-feed/feeds/rss.xml'
  articles = nil
  URI.open(url) do |rss|
    feed = RSS::Parser.parse(rss, false)
    articles = feed.items.map do |item|
      key = datastore.key "Article", item.link
      article = datastore.entity key do |a|
        a["title"] = item.title
        a["link"] = item.link
        a["description"] = item.description
        a["date"] = item.date
        a["posted"] = false
      end

      if datastore.find(key).nil?
        article
      else
        nil
      end
    end.compact
  end
  datastore.save(*(articles.uniq { |a| a.key.name }))

  "OK"
end

ツイート用関数

ツイート用関数のやっていることは主に3つです。

  1. Firestoreから記事情報を取得する
  2. OpenAI APIでツイートを生成する
  3. ツイートする

OpenAI APIでツイートを生成する

下記のようなプロンプトを送ってツイートを生成させます。 モデルはGPT-4 Turboを使っています。

  次の手順で、エンジニアのツイートを生成してください
  1. 「最近の記事」 を参照してニュースの記事の一覧を取得する
  2. 一覧の中から、ニュースを一つ選ぶ。選ぶニュースの優先度は、Rubyに関係するもの、関係者が多いもの、AIに関連するもの、の順にしてください。
  3. 選んだニュースを完結にまとめて、感想を含めて100字以内のツイートにしてください。
  
  口調は独り言っぽくしてください。あまり絵文字は使わず、まじめで信憑性が高そうな文章にしてください。ハッシュタグは1つも含めないでください。返答はツイートの内容のみで、それ以外の区切りの記号などは含めないでください。
  選んだ記事のURLをツイートに含めてください
  
  最近の記事
  Title:いいかんじの技術をいい感じにしました
  Description: いい感じの技術っていいですよね。こんにちは。
  URL: https://iikanji.test/article/iikannji-ha-iikannji
  (以下20個くらいつづく)

ツイートの長さの支持は100文字としています。Xは140字までツイートできますが、URLで12文字、AIの投稿であることを伝える文言が12文字あるのと、たまに暴発してちょっと文字数が増えることがあるためです。

ソースコード

require "functions_framework"
require 'google/cloud/datastore'
require 'x'
require 'openai'

x_credentials = {
  api_key: ENV['X_API_KEY'],
  api_key_secret: ENV['X_API_KEY_SECRET'],
  access_token: ENV['X_ACCESS_TOKEN'],
  access_token_secret: ENV['X_ACCESS_TOKEN_SECRET']
}
x_client = X::Client.new(**x_credentials)
store = Google::Cloud::Datastore.new(project_id: project_id)

FunctionsFramework.http "tweet" do |request|
  article_data = ""
  items = store.run(store.query("Article").order("date", :desc).limit(20)).filter do |article|
    !article["posted"]
  end
  items.each do |item|
    article_data << "Title: #{item["title"]}\n"
    article_data << "Description: #{item["description"]}\n"
    article_data << "Link: #{item["link"]}\n"
    article_data << "---\n"
  end

  prompt = <<~PROMPT
  次の手順で、エンジニアが興味を引くツイートを生成してください
  1. 「最近の記事」 を参照してニュースの記事の一覧を取得する
  2. 一覧の中から、ニュースを一つ選ぶ。選ぶニュースの優先度は、Rubyに関係するもの、関係者が多いもの、AIに関連するもの、の順にしてください。
  3. 選んだニュースを完結にまとめて、感想を含めて100字以内のツイートにしてください。
  
  口調は独り言っぽくしてください。あまり絵文字は使わず、まじめで信憑性が高そうな文章にしてください。ハッシュタグは1つも含めないでください。返答はツイートの内容のみで、それ以外の区切りの記号などは含めないでください。
  選んだ記事のURLをツイートに含めてください
  
  最近の記事
  #{article_data}
  PROMPT

  openai_client = OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
  responce = openai_client.chat(parameters: {
    model: "gpt-4-1106-preview",
    messages: [{ role: "user", content: prompt }],
  })
  tweet = responce.dig("choices", 0, "message", "content")
  url = tweet.scan(/https?:\/\/[\S]+/)[0]
  a = store.find("Article", url)
  raise "urlがうまいことパースできなかった" unless a
  a["posted"] = true
  store.save a

  x_client.post("tweets", "{\"text\": \"#{tweet  + " 🤖AIによる自動投稿です}\"}")
  [200, {}, [tweet]]
end

生成されるツイート

うまくいった例
いずれもちょっと鼻につく感じなのが玉にキズですが、それらしい文章が生成できています。

うまくいかなかった例

140文字を超えてしまうことがあります。この場合はXのAPIに弾かれてツイートに失敗します。

  • Rubyに関する直接の記事はないみたいだけど、AIが絡んでいる話題なら「パーソナライズド動画推薦システムをつくる | Gunosyデータ分析ブログ」というのがあるね。動画推薦エンジン、色々な要素を考慮してユーザーに合わせたコンテンツを提示してるんだって。技術の進歩って本当にすごいよね。興味深い内容だし、勉強にもなりそうだ。記事はこちらから読めるよ https://data.gunosy.io/entry/create_personalized_video_recommendation_system

出力に関しての説明を含めてしまうこともあります。

  • RubyやAIに関する直接的な記事は一覧にありませんが、技術者の教育についての内容が含まれる記事を選択しました。未経験からエンジニアになる苦労や学びを、GameWithが公開。管理者目線の教育法、役立つね。https://tech.gamewith.co.jp/entry/2023/12/11/180333

まとめ

  • OpenAI APIを使ってツイートを自動化したよ
  • いうことを聞いてくれないこともあるよ

OpenAI APIは非常に簡単に使うことができて、ChatGPTを使っているのとあまり変わらない感触でした。スゴイですね! AIなにもわからねぇ~、と漠然とした苦手意識があったのですが、簡単に動くものが作れることがわかり、もっといろいろ作ってみたい!という気になれました。