Raycast × Open AI API × Notion で自作英単語帳

昨年末の振り返りで、2023年こそは英語で本を読みたい、と書いたのですが、半年経ってまだ読めていなかったので易しめで軽いものを読み始めました。

The Art of Readable Code: Simple and Practical Techniques for Writing Better Code

日本語版も読んだことは無いですが、だいたい言っていることはわかるだろうと思ったし、日本語版だとだいぶ薄かった記憶があったので選びました。 しかし、読み進めていくと意外と知らない単語が多いなということに気づき、毎回ブラウザからググってメモするよりも、コマンド一発で普段使っているNotionのDBに登録してくれないかなと思ってやってみました。

動作

Raycastというランチャーアプリから登録しているスクリプトを実行することができます。 英単語を引数に与えてスクリプトを実行すると、OpenAI のAPIを呼んで英単語をもとに必要な情報をJSONで返させ、それを Notion 上に用意した英単語DBに格納するために、DBのレコード作成APIを呼ぶ、そういうスクリプトを書きました。

Raycastでなくともターミナルから同様のスクリプトを実行しても良いですが、Kindle を開きながらおもむろにスクリプトを実行するには、Raycastが便利でした。

Raycast

MacでランチャーアプリとしてRaycastを利用しています。

SpotlightやAlfredの代替として最近使われている方も多いように思います。

アプリケーションやファイルの検索、電卓、クリップボード履歴やスニペット辞書など便利な機能が沢山ありますが、今回はその中でもRaycast Script Commandという、登録しておいたスクリプトを実行できる機能を利用します。

Raycast Script Command

Raycast Script CommandにはBash, Apple Script, Swift, Python, Ruby, Node.jsで書かれたスクリプトを登録することができます。自動化したり、外部のAPIをたたいたり、何かと便利です。 登録しておいたスクリプトには、引数を最大3つまで設定することができたり、スクリプトにショートカットを割り当ててすぐに使えるようにできたりもします。

今回Rubyを利用しました。

Raycast 上で create script command とタイプし、作成ダイアログから作成すると。

お目当ての言語のファイルが作成され、ダイアログから作ったスクリプトにはメタ情報がファイル先頭に書き込まれています。

今回は、細かい Raycast Script Command の使い方は説明しません。

github.com

OpenAI

英単語帳には以下の情報がほしいなと思いました。

  • 日本語訳
  • 英英辞書的な説明
  • 類義語
  • 発音記号
  • 例文

簡単に使えそうな外部APIも見つからなかったので、OpenAI社のAPIを利用することにしました。 課金が必要ですが、LLMをはじめとしたAIモデルを利用できるAPIで、チャット/画像生成/文字起こし/テキストの分類などの機能があります。 今回は、チャットを利用して英単語から上記の情報をJSON形式で返させるということをしました。

実装

以下のようなスクリプトで OpenAI API を叩きます。

word = ARGV[0] # スクリプトの引数 = 英単語

uri = URI.parse('https://api.openai.com/v1/chat/completions')
request = Net::HTTP::Post.new(uri)
request.content_type = 'application/json'
request['Authorization'] = "Bearer #{open_ai_api_key}" # OpenAI APIのAPI key

request.body = {
  model: 'gpt-3.5-turbo',
  # 回答に至るまでのチャットを入力
  messages: [
    { role: 'system', content: system_message },
    { role: 'user', content: word },
    { role: 'system', content: '{ "additional_info":' }
  ]
}.to_json

req_options = { use_ssl: uri.scheme == 'https' }

response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
  http.request(request)
end

POSTパラメータには、AIモデルの指定をし、messagesというプロパティにAIが回答を出力するに至るまでのメッセージを入力します。

このメッセージは以下のような入力をしました。

## システムプロンプト

  Based on the English word entered by the user, output the "Japanese meaning", "English description", "thesaurus", "phonetic symbols", and "example sentences" in the following format (JSON).

  # Given word

  description

  # Output

  {
    "additional_info": "this is additional info",
    "ja": "記述、叙述、描写",
    "description": "a statement that represents something in words",
    "thesaurus": "account, characterization, chronicle, depiction, description, detail",
    "phonetic_symbols": "dɪskrípʃən",
    "examples": [
      "the description of the event was quite different from what had actually happened.",
      "The description of the book was accurate."
    ]
  }

## ユーザー(つまり自分)のメッセージ = 知りたい英単語

Dog

## システムプロンプト

{ "additional_info":

はじめのシステムプロンプトでは、モデルに対して「ユーザが入力した英単語をもとに、"日本語の意味"、"英語の説明"、"類語"、"発音記号"、"例文"を以下の形式(JSON)で出力してください。」という命令をしています。 その後に例として「description」という英単語と、それに対する想定する出力を ほしい形式で定義しています。 いわゆる one-shot learning というやつになっています。

最後のシステムプロンプトは、{ "additional_info":という文字列です。 レスポンスとして、JSONだけを返してほしいのですが単純にユーザーが入力するだけだと、JSONに付随して説明的な文章が返ってきてしまうことが多いです。それを防ぐための工夫として 先に{ "additional_info": を入力し、それに続けさせることで、JSONだけを出力してくれることを期待しています。返ってきた文字列の先頭に、このシステムプロンプトの文字列を繋げれば、正しい形のJSONになります。 この他にも、 ```json のようなものを入力する場合もあるようですが、僕の場合はこれがうまく行きました。

また、additional_info という key が JSONに含まれているのは、valueにその他の説明を入れるように暗に促しているという役割があります。

Notion

最後に、英単語とその情報を保存するためのNotionです。 ふだんはメモやタスク管理につかっていますが、英単語のテーブルを用意して1レコード1単語にしました。

APIによって、レコード追加や編集ができますが、こちらもAPIの利用には課金が必要です。

uri = URI.parse('https://api.notion.com/v1/pages')
request = Net::HTTP::Post.new(uri)
request.content_type = 'application/json'
request['Authorization'] = "Bearer #{notion_api_token}"
request['Notion-Version'] = '2022-06-28'

def create_content(text)
  { object: 'block', type: 'paragraph', paragraph: { rich_text: [{ type: 'text', text: { content: text } }] } }
end

request.body = {
  parent: { database_id: english_words_database_id },
  properties: {
    en: { title: [{ text: { content: ARGV[0] } }] },
    ja: { rich_text: [{ text: { content: res['ja'] } }] },
    sym: { rich_text: [{ text: { content: res['phonetic_symbols'] } }] }
  },
  children: [
    create_content(res['description']),
    create_content('## thesaurus'),
    create_content(res['thesaurus']),
    create_content('## examples')
  ].concat(res['examples'].map { |e| create_content(e) })
}.to_json

req_options = { use_ssl: uri.scheme == 'https' }

Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
  http.request(request)
end

今後

まず、OpenAI のAPIは単に遅いので2回目以降だと検索回数だけインクリメントするようにする、というのは追加しようと思います。

また、定期的に振り返りのために英単語の問題を出力するようにしたり、いくつかの英単語をピックアップして、長めの例文を生成させるみたいなのも試してみたいと思っています。

おわり

Raycast Script Commandはコードで作業を自動化できて楽しいです。

複雑なことをしたわけではないですが流行りのLLMを触ってみました。OpenAIのChat Completion APIの雰囲気はわかったのでこれから他のことにも試めせそうです。