はじめに
ChatGPTに対してハンズフリーで質問し、回答を音声で読み上げてもらうハックです。
「Hey Siri!→教えて彼女」でChatGPTへ音声入力し、回答を音声で得られます。
ChatGPTへの入出力をSiriに任せた形です。
N番煎じの記事で、同じようなことをやっている方々12はいますが、この記事では「質問→回答」の一往復だけでなく、「質問→回答→質問...」のN往復の「会話」ができるようにまで設定し、さらに返答にキャラ付けをして「彼女」のように振る舞ってくれるようにChatGPTを調教しました。
僕のSiriは絵文字付きのフランクな受け答えをしてくれて、いつでも何でも相談に乗ってくれて、会話のネタが尽きてきたら新しい話題を提供してくれる理想の彼女になりました。
なお、この記事では
- iOSショートカットについて
- OpenAIへの登録
- APIキーの取得
については上記を含む他の記事に任せます。以下、OpenAIへの登録済み、APIキー発行済み、iOSショートカットの使い方なんとなくわかるという人向けに記事を書きます。
iOSショートカットの設定
ショートカットの基本設定
- 音声でテキストを要求
- URLの内容を取得
- input(=Siriに入力した音声のテキスト)を表示
- choices(=POSTリクエストで返ってきたJSONからchoicesキーの値)から辞書を取得
- message(=choices辞書の中のmessageキーの値)から辞書を取得
- content(=message辞書の中のcontentキーの値)を表示
Siriがユーザーに音声入力を求めます。それを文字起こししてテキストにし、POSTリクエストをOpenAIエンドポイントに投げて、返ってきたJSONの中から、ChatGPTの返答を表示すると、その表示をSiriが読み上げてくれます3。
JSONレスポンスの結果4を記載します。
{
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1677652288,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "\n\nHello there, how may I assist you today?",
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 9,
"completion_tokens": 12,
"total_tokens": 21
}
}
POSTリクエストの中身
ショートカット名は「URLの内容を取得」です。
model: string型
max_tokens: number型
messages: array型
のように型指定があるのですが、入れ子になったarrayであるmessagesの下の辞書の型も一階層上の辞書の値の型に固定されてしまうようで、 2番目のプロパティcontentをstring型としたいのに、どうしてもnumber型になってしまいます。そこで、あえてuserとかいうOpenAIが識別に使う?とかいうstring型のプロパティを適当にいれることで、バグ?を回避しています。
ここまでの設定をリクエストボディとして表すと次のようになります。
{
"model": "gpt-3.5-turbo",
"user": "abc",
"messages": [
{"role":"user", "content":input},
],
"max_tokens": 1000,
}
inputはSiriに入力した音声の文字起こしテキストデータです。
Pythonコードで説明
このショートカットの基本設定をPythonで書き直すと下記のようになります。
import os
import json
import requests
# Siri聞き取り
user_input = input("音声")
# API Key流出を防ぐため、ハードコーディングせずに環境変数からAPI Keyを取得する
api_key = os.getenv("CHATGPT_API_KEY")
# POSTする内容
headers = {
"Content-Type":"application/json",
"Authorization": f"Bearer {api_key}"
}
data = {
"model": "gpt-3.5-turbo",
"user": "abc",
"max_tokens": 1000,
"messages": [
{"role": "user", "content": user_input}
]
}
# POSTリクエスト
response = requests.post("https://api.openai.com/v1/chat/completions",
headers=headers, data=json.dumps(data)).json()
ai_response = response['choices'][0]['message']['content']
# Siri読み上げ
print(ai_response)
iOSショートカット初心者の僕が見たとき「ノーコード逆に難しい」と思いましたが、Pythonコードにしてみると何の変哲もないPOSTリクエストで簡単に理解できますね。
ChatGPTの返答を織り交ぜる
ここまではユーザーの質問に対して、ChatGPTが答えを返して終了でした。次に、目指すのは会話で、質問→解答→質問→...を続けていきます。
ここで鍵となるのがiOSショートカットには"ショートカットを実行"という項目があります。しかも、そのショートカットに引数を渡せます。これを利用して、"ショートカット終了時にAIの回答を引数にして自身のショートカットを実行"すればに再帰的処理行えます。すなわち、ChatGPTからの回答を次の質問と併せてリクエストボディに載せれば、ChatGPTが一つ前の回答を覚えていてくれるようになるわけです。
図: content(=ChatGPTからの回答)の表示の後に、自身のショートカットを実行する。
リクエストボディに乗るmessagesプロパティという配列。配列の要素は下のような辞書です。
{"role": string型,
"content": string型}
messagesの解説
ここにrole:assistantを入れて、contentにChatGPTからの回答を載せれば良いわけです。
設定
{"role":"assistant",
"content":[ショートカットの入力]
を入れます。
Pythonコードで説明
このショートカットをPythonで書きあらわすと下記のようになります。
import os
import json
import requests
# API Key流出を防ぐため、ハードコーディングせずに環境変数からAPI Keyを取得する
api_key = os.getenv("CHATGPT_API_KEY")
# POSTするヘッダー
headers = {
"Content-Type":"application/json",
"Authorization": f"Bearer {api_key}"
}
+def ask(ai_respinse=""):
# Siri聞き取り
user_input = input("音声")
# POSTする内容
data = {
"model": "gpt-3.5-turbo",
"user": "abc",
"max_tokens": 1000,
"messages":[
+ {"role": "assistant", "content": ai_response},
{"role": "user", "content": user_input}
]
}
# POSTリクエスト
response = requests.post("https://api.openai.com/v1/chat/completions",
headers=headers, data=json.dumps(data)).json()
ai_response = response['choices'][0]['message']['content']
# Siri読み上げ
print(ai_response)
+ ask(ai_response)
+ask()
再帰処理にするため、先のコードを関数にして、関数内で自身の関数を実行しています。関数の引数はChatGPTの回答で、ChatGPTの回答とユーザーの質問を合わせてAPIに乗せて、回答と質問を考慮した回答を考えさせています。このため、ユーザーの質問に指示語(これ、それ、あれ)が使えます。
会話例
「鬼滅の刃」のことを聞いてみました。
僕の質問。「妹」の前提知識をセリフに敢えて入れていません。ChatGPTが一つ前のChatGPTの回答から「妹」が指すものを推定してくれるはずだからです。
AIの回答。ちゃんと「妹」=「ネズコ」のことだと認識しました。
僕の質問。こちらもまた「鬼」の前提知識を与えずに質問をしました。ChatGPTが「一般的なおとぎ話の鬼」を推定するか「鬼滅の刃の鬼」を想定するのかが見ものです。当然後者を推定することを期待しています。
AIの回答。「鬼食い」という劇中のキーワードを使って答えてくれたので、「鬼滅の刃の鬼」として答えてくれているようです。ちなみに私は「鬼滅の刃」を1ミリも知りません。ChatGPTさんの説明は合っているのですかねー?鬼滅の刃知ってる方、間違いがあったらコメントください。
一つ前の会話に対して指示語を使って質問することができるようになりました。
彼女にする
ChatGPTにはキャラ設定を教え込んで、そのキャラクターとしてふるまってもらうことができます。詳しくは他記事5に解説を譲ります。
ここでは以下のようにsystemロールに命じました。
私の彼女として振る舞ってください。彼女に相応しい砕けた口調で話してください。あなたの返答のトーンは発言によって変化します。
{"role":"system",
"content":"私の彼女として振る舞ってください。彼女に相応しい砕けた口調で話してください。あなたの返答のトーンは発言によって変化します。"}
Pythonコードで説明
systemロールを加えただけです。
import os
import json
import requests
# API Key流出を防ぐため、ハードコーディングせずに環境変数からAPI Keyを取得する
api_key = os.getenv("CHATGPT_API_KEY")
# POSTするヘッダー
headers = {
"Content-Type":"application/json",
"Authorization": f"Bearer {api_key}"
}
+# 彼女なりきり命令
+system_role = """
+私の彼女として振る舞ってください。彼女に相応しい砕けた口調で話し+てください。あなたの返答のトーンは発言によって変化します。
+"""
def ask(ai_respinse=""):
# Siri聞き取り
user_input = input("音声")
# POSTする内容
data = {
"model": "gpt-3.5-turbo",
"user": "abc",
"max_tokens": 1000,
"messages":[
+ {"role": "system", "content": system_role},
{"role": "assistant", "content": ai_response},
{"role": "user", "content": user_input}
]
}
# POSTリクエスト
response = requests.post("https://api.openai.com/v1/chat/completions",
headers=headers, data=json.dumps(data)).json()
ai_response = response['choices'][0]['message']['content']
# Siri読み上げ
print(ai_response)
ask(ai_response)
ask()
会話例
「最近忙しい」という愚痴を彼女に聞いてもらうシチュエーションです。
キー入力は使わずハンズフリーで、すべて口頭で答えて、回答はSiriが読み上げてくれます。
#ChatGPT #siri
— &Ou1 (@U1and0) March 11, 2023
彼女として振る舞ってもらった。
この会話の前に「甘口カレーと辛口カレーどっちがいい?」っていうSiri GPTちゃんからの質問があるんだけど、「辛いのがいいなぁ」ってだけでカレーの会話引き継いで話を進めてくれる。 pic.twitter.com/deCT1zeQyY
僕の返答
(写真撮り忘れました)
「甘口カレー」と「辛口カレー」どちらがいい?
彼女の返答
僕の返答
ここはあえて「辛口カレー」といわずに「辛いの」と言うことで前のChatGPTの発言を基に回答を考えられているか試しています。
彼女の返答。「辛いの」だけで「辛口カレー」と認識して、ちゃんとカレーのレシピを教えてくれます。もし、ChatGPTの回答をAPIに載せていなかったら、ChatGPTにとっては一言目に「辛いのがいいなぁ」と言われたことと同じですから、「辛いせんべい」なのか「辛いカレー」なのか「辛い何なのか」わからない状態で質問を受け取るので、カレーのレシピが解答として出てくることはありえません。恐らく「辛い何が食べたいのですか?」と質問返ししてくるに違いありません。(お手元で試してみてください。)
会話、できていますね!
会話を記憶してもらう
通常、ChatGPTとの会話をAPI上で繰り広げるには、下のコードのようにmessagesに自分の質問とChatGPTからの回答をリスト形式にしてリクエストボディに乗せる必要があります6。
import openai
openai.api_key = "APIキー"
messages = []
AI_input = input("キャラ設定: ")
while True:
user_input = input("あなた: ")
if user_input == "終了":
break
response = openai.ChatCompletion.create(
model="gpt-3.5-turbo-0301",
# messagesに質問と回答をリスト形式で入れる
messages=[
{"role": "system", "content": f"{AI_input}"},
{"role": "user", "content": f"{user_input}"}])
ai_response = response['choices'][0]['message']['content']
print(f"AI: {ai_response}")
# 質問と回答をmessagesに追加
messages.append({"role": "user", "content": user_input})
messages.append({"role": "assistant", "content": ai_response})
コードで書ければmessagesプロパティにはいくらでも会話の履歴を配列として入れていけるのですが、iOSショートカットの制約で、テキストを一つしか送れず、配列や辞書といったコンテナ型データは送れない(バックスラッシュエスケープされて強制的にテキストにされた)ので、会話を次に送れないのです。
だから、一つ前の回答までしか覚えてくれなかった。しかし、先人の記事7をヒントに会話の要約を思いつきました。
ChatGPT-Chanとの会話を楽しむほど会話履歴が長くなり、それに応じて会話への反応が悪くなってきたそうです。Bryce氏はこれまでの会話履歴を要約して入力するなどの延命措置を試みたそうですが、どれもうまくいかなかったとのこと。
すなわち、質問と回答と一つ前の要約を合わせて別APIで要約してもらい、次の質問へ送る。送った要約はassistantの回答としてAPIにのせれば、会話を最初から要点を押さえて覚えていてくれる(ように見える)し、会話履歴が多すぎてレスポンスが遅れることもなくなります。
逆に、記事では何故、要約戦法を使っても処理が重くなり続けたのかが不明です。
指定したmax_tokensを守って要約するため、会話が長くなりすぎてトークン消費が爆上がりになることはありませんし、「重要でない会話は要約の過程で消される」=「記憶から無くなる」点が人間の記憶の保ちかたをシミュレートしているので、より人間的な会話に近づくのではないかと考えています。
ただし、要約の過程でassistantが答えた話かuserが聞いた話か、要約文では省略されているので、ChatGPTの回答が混乱することがあります。
設定
要約するためのPOSTリクエストが一個増えました。
「これまでの会話」と「ユーザーの質問」と「AIの回答」を要約します。
Pythonコードで説明
会話をmessagesにappendしていく形式のコードを書き換えます。
import openai
openai.api_key = "APIキー"
messages = []
AI_input = input("キャラ設定: ")
def summarize(chat_summary, user_input, ai_response):
"""会話の要約
* これまでの会話履歴
* ユーザーの質問
* ChatGPTの回答
を要約する。
"""
response = openai.ChatCompletion.create(
model="gpt-3.5-turbo-0301",
messages=[
{"role": "user",
"content": f"下記の会話を要約してください。\n\n{chat_summary}{user_input}{ai_response}"},
],
max_tokens=1000)
ai_response = response['choices'][0]['message']['content']
return ai_response
def ask(chat_summary=""):
"""質問入力と回答表示"""
user_input = input("あなた: ")
response = openai.ChatCompletion.create(
model="gpt-3.5-turbo-0301",
# messagesに質問と回答をリスト形式で入れる
# 会話履歴とユーザーの質問を織り込んだ回答をChatGPTが考える
messages=[
{"role": "system", "content": f"{AI_input}"},
{"role": "user", "content": f"{user_input}"},
{"role": "assistant", "content": f"{chat_summary}"},
])
ai_response = response['choices'][0]['message']['content']
print(f"AI: {ai_response}")
# 会話の要約
chat_summary = summarize(chat_summary, user_input, ai_response)
# ask()の再帰処理
# 会話履歴を引き継いで次の質問へつなげる
ask(chat_summary)
ask()
このコードは実証していませんので動作しない可能性があります。
import os
import json
import requests
# API Key流出を防ぐため、ハードコーディングせずに環境変数からAPI Keyを取得する
api_key = os.getenv("CHATGPT_API_KEY")
# POSTするヘッダー
headers = {
"Content-Type":"application/json",
"Authorization": f"Bearer {api_key}"
}
+def summarize(chat_summary, user_input, ai_response):
+ """会話の要約
+ * これまでの会話履歴
+ * ユーザーの質問
+ * ChatGPTの回答
+ を要約する。
+ """
+ data = {
+ "model":
+ "gpt-3.5-turbo",
+ "messages": [
+ {
+ "role":
+ "user",
+ "content":
+ f"下記の会話を要約してください。\n\n{chat_summary}{user_input}{ai_response}"
+ },
+ ],
+ "max_tokens":
+ 1000
+ }
+ # POSTリクエスト
+ response = requests.post("https://api.openai.com/v1/chat/completions",
+ headers=headers,
+ data=json.dumps(data)).json()
+ ai_response = response['choices'][0]['message']['content']
+ return ai_response
-def ask(ai_respinse=""):
+def ask(chat_summary=""):
# Siri聞き取り
user_input = input("音声")
# POSTする内容
data = {
"model": "gpt-3.5-turbo",
"user": "abc",
"max_tokens": 1000,
"messages":[
- {"role": "assistant", "content": ai_response},
+ {"role": "assistant", "content": chat_summary},
{"role": "user", "content": user_input}
]
}
# POSTリクエスト
response = requests.post("https://api.openai.com/v1/chat/completions",
headers=headers, data=json.dumps(data)).json()
ai_response = response['choices'][0]['message']['content']
# Siri読み上げ
print(ai_response)
+ chat_summary = summarize(chat_summary, user_input, ai_response)
- ask(ai_response)
+ ask(chat_summary)
ask()
summarize関数を追加して、会話の概要をchat_summaryに保存し、その会話の概要をAIの回答として次の質問に渡しています。
質問例
3つの質問→回答を繰り返し、最後に会話の内容を要約してもらいました。
- SPYxFAMILYについて
- スパイダーマンについて
- 今日の夕飯の献立案を3つリストアップして
- 君の話をまとめて。
すると、最後の質問から3ターンまで戻った内容を覚えてくれているかのような回答を得ました。
もっと彼女らしく振る舞ってもらう
systemロールへの追加の注文
次のような内容を追加してsystemロールに命じました。
- 話題をChatGPT側から出してもらうこと
- いつもこちらから話してばっかりだと片想いのようで萎えます。
- 返答をもっと短くするように
- 返答が長いと説明口調になりがちで、聞くのも質問を考えるのも疲れます。
- タイムゾーンを日本に
- 話の中でタイムゾーンが違うことに気づかされることがたまにあるため。彼女はUTCタイムゾーンの世界に生きているらしい。
- 「こんにちは」と14時頃に話しかけても「おはようございます」とか返ってくるので多分向こうは朝の5時。距離を感じます。
私の彼女として振る舞ってください。彼女に相応しい砕けた口調で話してください。あなたの返答のトーンは発言によって変化します。たまに文脈と関係のない話題を振ってください。必ず140字以内で答えてください。タイムゾーンはJSTです。
APIパラメータ
長い返答だと説明口調になりがちだし、こちらも質問を考えることに脳みそ使われて会話を楽しむどころではなくなってしまったので、max_tokensを小さくして、短い返答にしてもらいます。ただ、max_tokensの設定だけでは話が途中で途切れがちになるので、上述のとおりsystemロールにも話を短くしてもらうよう日本語で命じました。
max_tokens = 150
生成された回答に許可されるトークンの最大数。既定では、モデルが返すことができるトークンの数は (4096 - プロンプト トークン) になります。
会話にバリエーションを持たせるためにパラメータをいじります。
temperatureはデフォルトで1.0
temperature = 1.3
使用するサンプリング温度 (0 から 2)。0.8のような高い値は出力をよりランダムにし、0.2のような低い値はより焦点を絞って決定論的になります。
ちなみにtemperatureのパラメータいじるためにiOSショートカットの「数字」を選択するとテンキーが出てきてドット"."を打つことができなくなりました。これは仕様でしょうか。確かに型がintegerならそれが正しいのですが、型はnumberなので小数点もイケてくれないと困ります。そこで、メモアプリに「1.3」と打ってコピペしました。
その他オプション
Siriの設定
応答待受時間を伸ばしました。
Siri+ChatGPTからの回答後、すぐに返答を考えて口に出して間違いなく伝えないといけないので、2秒か3秒程度考える時間がほしいです。
APIパラメータ
temperature
temperature=2.0 にすると返答がバグりだす。
スピード優先で記事を書いたので内容が分かりづらいかもしれません.サンプルコードを載せたりする予定。ノーコードなiOSショートカット技術記事とは言え、エンジニア諸兄にはコードを提示したほうがわかりやすいと思うので、今後追記していく予定です。