ChatGPTでコンテキストを理解したTwitchチャット両方向翻訳を作る

[Beat Saber Advent Calendar 2023](https://adventar.org/calendars/8661) ４日目の記事でmp3を１万曲落とすコード書いて遊んでたけども早々に書き終わっちゃったので別の遊びを探してた

https://qiita.com/akiraak/items/181e5155434b699803c5

# もくじ

* [全体の処理の流れ](#全体の処理の流れ)
* [翻訳の精度を上げるためにメッセージから不要なものを削除](#翻訳の精度を上げるためにメッセージから不要なものを削除)
* [日本語か判定する](#日本語か判定する)
* [日本語は英語に、日本語以外は日本語に翻訳する](#日本語は英語に日本語以外は日本語に翻訳する)
* [おわりに（スラング・意訳・関係性の翻訳）](#おわりにスラング意訳関係性の翻訳)
* [おまけのあキぬこAIにチャット翻訳について聞いてみた](#おまけのあキぬこaiにチャット翻訳について聞いてみた)

おもろいのできた！

![Cursor_と_あキら_-_Twitch.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33704/5dc246e0-22c6-e55a-44c9-d5c4f397b984.png)


登場人物

* あキら ぼく
* あキぬこ 猫AI
* あキ箱 翻訳AI

ヒューマンは僕だけで、他２匹はAIです。韓国語もちゃんと翻訳できてるっぽい？韓国語読めないけど!
ハングルと日本語が混ざっててもいけてる

![Cursor_と_あキら_-_Twitch-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33704/685d35d2-1963-c4e2-80e9-1d2eac5aa498.png)

ドイツ語もできた。過去の履歴も考慮して会話＆翻訳してるから、不要な部分「あキ箱、これを翻訳できるかにゃ？」は省かれてるし「ドイツには美味しい料理がたくさんあるにゃ！」。とぬこの口調も再現してる。賢いのぉ

さて、コードの説明です。GitHubのリポジトリはこちら

https://github.com/akiraak/ChatGPTTwitchTranslator

コードは１ファイルにまとめてあるのだ。下で順番に説明します。

```py:run.py
import argparse
import json
import os
import re
import signal

from openai import OpenAI
from twitchio import Channel, Message
from twitchio.ext import commands


# 環境変数で設定する
TWITCH_CHAT_OAUTH_PASSWORD = os.getenv("TWITCH_CHAT_OAUTH_PASSWORD")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")

CHATGPT_MODEL = "gpt-4-1106-preview"
COMMAND_PREFIX = "!"


# ChatGPT API を叩く
def fetch_chatgpt(user_content: str, system_content: str = None) -> str:
    client = OpenAI(api_key=OPENAI_API_KEY)

    messages = [{"role": "user", "content": user_content}]
    if system_content is not None:
        messages.append({"role": "system", "content": system_content})

    response = client.chat.completions.create(
        messages=messages,
        model=CHATGPT_MODEL,
    )
    return response.choices[0].message.content


# メッセージを指定の言語で翻訳する
def translate_language(language: str, message: str, message_history: list[str]) -> (bool, str):
    NONE_TAG = "#NONE#"

    message_history_text = '\n'.join(message_history)
    system_content = f"""あなたは投稿されたメッセージを{language}に翻訳するボットです。
メッセージは `[ユーザー名]:[メッセージ]` の形式で投稿されていますが、メッセージのみを翻訳してください。
翻訳できない場合は{NONE_TAG}と返してください。
過去のメッセージの履歴が存在する場合はそれも考慮して翻訳してください。
==== 過去のメッセージの履歴 ====
{message_history_text}
==== 過去のメッセージの履歴ここまで ====
"""

    user_content = f"{message}"

    translated_message = fetch_chatgpt(user_content=user_content, system_content=system_content)
    if NONE_TAG in translated_message:
        return False, ""

    return True, translated_message


# メッセージを英語に翻訳する
def translate_english(message: str, message_history: list[str]) -> (bool, str):
    return translate_language(language="英語", message=message, message_history=message_history)


# メッセージを日本語に翻訳する
def translate_japanese(message: str, message_history: list[str]) -> (bool, str):
    return translate_language(language="日本語", message=message, message_history=message_history)


# メッセージが日本語かどうか判定する
def is_japanese(message: str) -> bool:
    JAPANESE_TAG = "ja"
    OTHERS_TAG = "others"

    system_content = f"""You are an AI designed to identify the language of a text.
If the input text is in Japanese, please return {JAPANESE_TAG}.
For languages other than Japanese, please return {OTHERS_TAG}."""

    user_content = f"{message}"

    language = fetch_chatgpt(system_content=system_content, user_content=user_content)

    return JAPANESE_TAG in language


# メッセージからユーザーへのメンション(@XXXX)を削除する
def remove_user_mentions(message: str) -> str:
    pattern = r'@\S+'
    return re.sub(pattern, '', message)


# メッセージから絵文字(emotion)を削除する
def remove_emotions(message: str, emotion_info_raw: str) -> str:
    """
    Twitchで絵文字付きでメッセージを送信した場合は以下のような形式でmessageが送られてきます。
    ちわわ aaaaaaaaaaaa bbbbbbbbbb bbbbbbbbbb ccccccccccccc ccccccccccccc ccccccccccccc
    「ちわわ」以下が全て絵文字です。

    また絵文字のメタ情報（emotion_info_raw）として以下の情報も付随します。
    emotesv2_8c9e7298986244548c4838c4b49d8462:4-15/emotesv2_41f525cb751c4ed4a41d3242283229ed:17-26,28-37/emotesv2_63ac2695c6d24c129033a06d5f60ecd6:39-51,53-65,67-79
    このメタ情報から 4-15 = aaaaaaaaaaaa, 17-26 = bbbbbbbbbb, 39-51 = ccccccccccccc を導き出します。
    そして message からこれらの文字列を削除します。
    """

    emotion_words = []
    for emotion_info in emotion_info_raw.split('/'):
        if ':' in emotion_info:
            _, positions = emotion_info.split(':')
            for pos in positions.split(','):
                start, end = map(int, pos.split('-'))
                emotion_words.append(message[start:end+1])
                break

    for emotion_word in emotion_words:
        message = message.replace(emotion_word, '')

    return message


# Twitch のチャットボット
class Bot(commands.Bot):
    MAX_MESSAGE_HISTORY = 20 # 過去のメッセージの履歴の最大数

    # 初期化＆ログイン
    def __init__(self, channel: str, send_messages: bool):
        super().__init__(
            token=TWITCH_CHAT_OAUTH_PASSWORD, prefix=COMMAND_PREFIX, initial_channels=[channel]
        )
        self.message_history = []
        self.send_messages = send_messages

    # ログイン完了時の処理
    async def event_channel_joined(self, channel: Channel):
        print("---- event_channel_joined ----")
        print(f"チャンネル名: {channel.name}\n")

    # メッセージ受信時の処理
    async def event_message(self, message: Message):
        # ボットの発言は無視する
        if message.echo:
            return

        # メッセージがコマンドであれば処理を終了
        if message.content.startswith(COMMAND_PREFIX):
            return

        # ユーザー名の表示を整える
        if message.author.display_name != message.author.name:
            formatted_user_name = f"{message.author.display_name}({message.author.name})"
        else:
            formatted_user_name = message.author.name

        # 1. メッセージから絵文字(emotion)を削除する
        # 注意: ユーザーメンションよりも先に絵文字を削除する必要がある
        cleaned_message = message.content
        if message.tags and message.tags['emotes']:
            cleaned_message = remove_emotions(message=cleaned_message, emotion_info_raw=message.tags['emotes'])
        # 2. メッセージからユーザーへのメンション(@XXXX)を削除する
        cleaned_message = remove_user_mentions(message=cleaned_message)

        # 名前とメッセージを結合する
        formatted_message_line = f"{formatted_user_name}: {cleaned_message}"

        # 3. メッセージが日本語かどうか判定する
        is_message_japanese = is_japanese(message=cleaned_message)

        print(f"{formatted_user_name}: {message.content}")

        # 4. メッセージを翻訳する
        sending_message = None
        if is_message_japanese:
            # 4-1. 日本語の場合は英語に翻訳する
            success, translated_to_english_message = translate_english(message=formatted_message_line, message_history=self.message_history)
            if success:
                sending_message = f"en> {translated_to_english_message}"
                print(f"{formatted_user_name}: {sending_message}\n")
            else:
                print("\n")
        else:
            # 4-2. 日本語以外の場合は日本語に翻訳する    
            success, translated_to_japanese_message = translate_japanese(message=formatted_message_line, message_history=self.message_history)
            if success:
                sending_message = f"ja> {translated_to_japanese_message}"
                print(f"{formatted_user_name}: {sending_message}\n")
            else:
                print("\n")

        # 5. メッセージの履歴をためる
        self.message_history.append(formatted_message_line)
        self.message_history = self.message_history[-self.MAX_MESSAGE_HISTORY:]

        # 6. メッセージの送信
        if self.send_messages and sending_message:
            await message.channel.send(f"/me {sending_message}")


def main():
    parser = argparse.ArgumentParser(description="TwitchTranslator")
    parser.add_argument(
        "-c", "--channel", type=str, required=True, help="入室するチャンネル名"
    )
    parser.add_argument(
        "-s", "--send-messages", action="store_true",
        help="メッセージを送信するかどうか"
    )
    args = parser.parse_args()

    bot = Bot(channel=args.channel, send_messages=args.send_messages)
    bot.run()


if __name__ == "__main__":
    signal.signal(signal.SIGINT, signal.SIG_DFL)
    main()
```

# 全体の処理の流れ

メッセーを受け取ってから翻訳してチャットにメッセージを送信するまでの流れはこのようになっています。

1. メッセージから絵文字(emotion)を削除する
2. メッセージからユーザーへのメンション(＠XXXX)を削除する
3. メッセージが日本語かどうか判定する
4. メッセージを翻訳する
  4-1. 日本語の場合は英語に翻訳する
  4-2. 日本語以外の場合は日本語に翻訳する
5. メッセージの履歴をためる
6. メッセージの送信

メッセージにはユーザーへのメンションやスタンプなどが付いてる場合があるので、最初にそれを削除します。
次にメッセージが日本語なのか、それ以外の言語なのか判定します。
日本語であれば英語に、それ以外の言語であれば日本語に翻訳します。
そしてチャット欄に送信します。

メッセージの履歴をためるタイミングはどこでもよいです。
この翻訳では過去のメッセージの履歴２０個までを考慮しながらメッセージの翻訳をおこなっています。

この記事では Twitch ボットの作り方は説明していませんが、別の記事で超簡単に作る方法を書いてるので参考にしてください。

https://qiita.com/akiraak/items/f96736218b6750e69dbc

# 翻訳の精度を上げるためにメッセージから不要なものを削除

メッセージの中にメンションやスタンプが含まれていると、その後の言語判定や翻訳をミスる時がありました。そうならないように不要なものは削除してしまいます。これはTwitchのチャット翻訳に広く使われている [twitchTransFreeNext](https://github.com/sayonari/twitchTransFreeNext) の処理を参考にさせてもらいました。

https://github.com/sayonari/twitchTransFreeNext

スタンプを除去する処理は少し複雑です。

```py
        # メッセージから絵文字(emotion)を削除する
        # 注意: ユーザーメンションよりも先に絵文字を削除する必要がある
        cleaned_message = message.content
        if message.tags and message.tags['emotes']:
            cleaned_message = remove_emotions(message=cleaned_message, emotion_info_raw=message.tags['emotes'])
```

```py
# メッセージから絵文字(emotion)を削除する
def remove_emotions(message: str, emotion_info_raw: str) -> str:
    """
    Twitchで絵文字付きでメッセージを送信した場合は以下のような形式でmessageが送られてきます。
    ちわわ aaaaaaaaaaaa bbbbbbbbbb bbbbbbbbbb ccccccccccccc ccccccccccccc ccccccccccccc
    「ちわわ」以下が全て絵文字です。

    また絵文字のメタ情報（emotion_info_raw）として以下の情報も付随します。
    emotesv2_8c9e7298986244548c4838c4b49d8462:4-15/emotesv2_41f525cb751c4ed4a41d3242283229ed:17-26,28-37/emotesv2_63ac2695c6d24c129033a06d5f60ecd6:39-51,53-65,67-79
    このメタ情報から 4-15 = aaaaaaaaaaaa, 17-26 = bbbbbbbbbb, 39-51 = ccccccccccccc を導き出します。
    そして message からこれらの文字列を削除します。
    """

    emotion_words = []
    for emotion_info in emotion_info_raw.split('/'):
        if ':' in emotion_info:
            _, positions = emotion_info.split(':')
            for pos in positions.split(','):
                start, end = map(int, pos.split('-'))
                emotion_words.append(message[start:end+1])
                break

    for emotion_word in emotion_words:
        message = message.replace(emotion_word, '')

    return message
```

`message.tags['emotes']` の中にメッセージの中に含まれるスタンプの情報が入ってくるので、そこからスタンプの文字列を特定して、その文字列をメッセージの中から削除します。

ユーザーのメンションはとっても単純です。正規表現でヒットさせて削除するだけです。

```py
# メッセージからユーザーへのメンション(@XXXX)を削除する
def remove_user_mentions(message: str) -> str:
    pattern = r'@\S+'
    return re.sub(pattern, '', message)
```

# 日本語か判定する

メッセージを綺麗にしたところでまずは日本語がどうか判定します。最初は日本語だったら英語に、それ以外だったら日本語に翻訳するChatGPTのプロンプトを作ってみたけどあまり精度が出なかったので、言語判定と翻訳の２段階に分けることにしました。

その前に、ChatGPTの送信は１つの関数にまとめてあります。
user_content, system_content を受け取って、それを送信して結果を返します。

```py
# ChatGPT API を叩く
def fetch_chatgpt(user_content: str, system_content: str = None) -> str:
    client = OpenAI(api_key=OPENAI_API_KEY)

    messages = [{"role": "user", "content": user_content}]
    if system_content is not None:
        messages.append({"role": "system", "content": system_content})

    response = client.chat.completions.create(
        messages=messages,
        model=CHATGPT_MODEL,
    )
    return response.choices[0].message.content
```

メッセージが日本語だったら `ja` そうじゃなかったら `others` って返してねっていうプロンプトです。

```py
# メッセージが日本語かどうか判定する
def is_japanese(message: str) -> bool:
    JAPANESE_TAG = "ja"
    OTHERS_TAG = "others"

    system_content = f"""You are an AI designed to identify the language of a text.
If the input text is in Japanese, please return {JAPANESE_TAG}.
For languages other than Japanese, please return {OTHERS_TAG}."""

    user_content = f"{message}"

    language = fetch_chatgpt(system_content=system_content, user_content=user_content)

    return JAPANESE_TAG in language
```

ただし、このプロンプトでの正解率は９０％くらいです。とくに和製英語が混じった場合に日本語と判定してしまう時がありました。

# 日本語は英語に、日本語以外は日本語に翻訳する

判定が終わったところでそれぞれの言語に翻訳します。
翻訳の関数は共通のものを作っておき、言語を引数で与えます。

```py
# メッセージを指定の言語で翻訳する
def translate_language(language: str, message: str, message_history: list[str]) -> (bool, str):
    NONE_TAG = "#NONE#"

    message_history_text = '\n'.join(message_history)
    system_content = f"""あなたは投稿されたメッセージを{language}に翻訳するボットです。
メッセージは `[ユーザー名]:[メッセージ]` の形式で投稿されていますが、メッセージのみを翻訳してください。
翻訳できない場合は{NONE_TAG}と返してください。
過去のメッセージの履歴が存在する場合はそれも考慮して翻訳してください。
==== 過去のメッセージの履歴 ====
{message_history_text}
==== 過去のメッセージの履歴ここまで ====
"""

    user_content = f"{message}"

    translated_message = fetch_chatgpt(user_content=user_content, system_content=system_content)
    if NONE_TAG in translated_message:
        return False, ""

    return True, translated_message


# メッセージを英語に翻訳する
def translate_english(message: str, message_history: list[str]) -> (bool, str):
    return translate_language(language="英語", message=message, message_history=message_history)


# メッセージを日本語に翻訳する
def translate_japanese(message: str, message_history: list[str]) -> (bool, str):
    return translate_language(language="日本語", message=message, message_history=message_history)
```

重要なところは過去のメッセージの履歴が含まれているところと、ユーザー名も含めて翻訳に渡しているところです。しかし翻訳はユーザー名を無視しコメント部分のみ行わせています。

まだあまりテストはしていませんが、過去の履歴から、そのユーザーや周囲のユーザーの関係も考慮して翻訳が行われるのではないるように思います。

# 実行

環境は Python3 です。3.9 で試してるけど 3 以上ならたぶん動くはず。
必要があれば venv をしてください。

```sh
$ python3 -m venv .venv
$ . .venv/bin/activate
```

モジュールのインストール

```sh
$ pip install -r requirements.txt
```

Twitch OAuth Password と OpenAI API Key は環境変数から設定する必要があります。

```py
# 環境変数で設定する
TWITCH_CHAT_OAUTH_PASSWORD = os.getenv("TWITCH_CHAT_OAUTH_PASSWORD")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
```

コマンドラインなどから設定してください。コードを直に編集しちゃっても大丈夫です。

```sh
$ export TWITCH_CHAT_OAUTH_PASSWORD="XXXXXXXXXXXXXXXXXXXXXXXXXXX"
$ export OPENAI_API_KEY="XXXXXXXXXXXXXXXXXXXXXXXXXXX"
```

実行

```sh
$ python run.py --channel akiraaak --send-massage
```

--channel は Twitch のユーザー名です。
--send-massage を指定しないとチャット欄に翻訳を送信せずに、コンソール画面にだけ翻訳結果を表示します。

# おわりに（スラング・意訳・関係性の翻訳）

翻訳はむずかしい

u a great! を「あなたは素晴らしい！」と訳してくれた
www を lol と訳してくれた

I'm just looking　はなんと訳すだろうか？
残念ながらどんな質問をされたかによって変わってくる

なのでコンテキスト（メッセージの履歴）がとても重要になってくると思う。配信者のコメントも履歴に含められたらもっと完璧になるんだろうなぁ

# おまけのあキぬこAIにチャット翻訳について聞いてみた

![あキら_-_Twitch-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33704/e6c2e646-9d9d-86cb-5f0f-9155a34608ee.png)

あキら (akiraaak): ぬこさんこんにちは

あキ箱 (akiraak_box) en> Hello, Mr. Cat.

あキぬこ (akiraak_familiar) こんにちはにゃん！あキら、今日も元気っぽくていいねにゃん♪

あキ箱 (akiraak_box) en> Hello, nyaa! Akira, you seem to be in good spirits today as well, nice to see, nyaa♪

あキら (akiraaak): あキ箱という翻訳ボットを作ったんだけど。チャットにおける翻訳機能について話をみてみよう ぬこ

あキぬこ (akiraak_familiar) おお！あキらが翻訳ボットを作ったんだね！すごいにゃん！チャットで翻訳を使うと、いろんな国の人と話せるようになって便利にゃんね。なにか翻訳について知りたいコトあるかにゃ？

あキ箱 (akiraak_box) en> あキら(akiraaak): I created a translation bot called "Aki Box". Let's discuss the translation feature in chat. Nuko.

あキ箱 (akiraak_box) en> Wow! Looks like Akira has made a translation bot! That's amazing, nyaa! Using translation in chats makes it convenient to talk with people from different countries, nyaa. Do you have any questions about translation that you'd like to know, nyaa?

あキら (akiraaak): まず最初に、チャットに翻訳機能が入るとどんな良いことがある？

あキら (akiraaak): ぬこ

あキ箱 (akiraak_box) en> First off, what are the benefits of having a translation feature in a chat?

あキぬこ (akiraak_familiar) チャットに翻訳機能があると、言語の壁がなくなって、世界中どこにいてもコミュニケーションができるようになるにゃん！文化の違いを学んだり、新しい友だちを作ったり、情報共有がもっとスムーズになるんだにゃん。でも、翻訳がちょっとおかしくなることもあるから気をつけないといけないにゃんよ！

あキ箱 (akiraak_box) en> If chat has a translation feature, it removes language barriers, allowing us to communicate from anywhere in the world, nyaa! It helps us learn about cultural differences, make new friends, and share information more smoothly, nyaa. But we have to be careful because sometimes translations can be a bit off, nyaa!

あキら (akiraaak): 新しい友だちも増えて、そこから更に多文化を学んで、更に友達が増えていくいいスパイラルだね。悲しいことも起きそうだけど。翻訳がおかしいとどんな問題がおきる？ぬこ

あキ箱 (akiraak_box) en> It's a good spiral where you make new friends, learn more about different cultures, and then you make even more friends. It seems like some sad things might happen too. What kind of problems can arise if the translation is wrong? Nuko

あキぬこ (akiraak_familiar) 翻訳がおかしくなると、誤解が生まれちゃったり、伝えたいことが正しく伝わらなかったりする問題が起きるにゃん。時には、ちょっとした誤訳でお互いに気分を害することもあるかもしれないにゃん。だから、翻訳を使う時は、ちょっとずつ確認しながら進めるのが大切にゃんよ！

あキ箱 (akiraak_box) en> When translation goes wrong, misunderstandings can arise, and the message intended to be conveyed may not be correctly received. Sometimes, even a slight mistranslation can cause both parties to feel offended. Therefore, it's important to proceed with translation cautiously, checking little by little.

あキら (akiraaak): それは怖いね。ちょっとしたすれ違いから喧嘩が起きることもありそう。翻訳が間違ってたとして、それでもお互いのディスコミュニケーションを正常なものにしようとしたい時はどうしたらいいだろ？ぬこ

あキ箱 (akiraak_box) en> That's scary. It seems like a fight could start from a simple misunderstanding. Even if the translation is incorrect, how should we try to correct the discommunication between each other? Nuko

あキぬこ (akiraak_familiar) お互いに誤解があったときは、まずは落ち着いて、翻訳したメッセージが正しいかどうか確認することが大事にゃん。できれば、もう一度自分の言いたいことを違う言葉で伝えてみるといいかもしれないにゃん。相手が言いたいことをじっくりと理解しようとする姿勢も大切にゃん。時には絵文字や画像を使って感情を伝えるのも助けになるにゃんよ！

あキ箱 (akiraak_box) en> When misunderstandings arise between us, it's important to stay calm and check if the translated message is accurate. It might help to try to express what you want to say again in different words. It's also crucial to show an attitude of carefully trying to understand what the other person wants to say. Sometimes, using emojis or images to convey emotions can be helpful too!

あキら (akiraaak): なるほど。１回の会話で終わらずに「それってこいうこと？」って聞き返しながらお互いの考えを確認していくのは確かに良い方法だね。何よりも相手を理解したいっていう姿勢が大切なんだね

あキら (akiraaak): ぬこ

あキ箱 (akiraak_box) en> I see. It's indeed a good method to not just end the conversation at one exchange but to ask back "Does it mean this?" to confirm each other's thoughts. Most importantly, having the attitude of wanting to understand the other person is crucial, isn't it?

あキぬこ (akiraak_familiar) うんうん、そうなんだにゃん！「それってこいうこと？」って聞き返すのは、すごくいい方法にゃん。お互いを尊重して、理解しようとする姿勢が一番重要だにゃん。ちょっとした工夫で、コミュニケーションはずっと良くなれるんだから、頑張ってみるといいかもにゃん！

あキ箱 (akiraak_box) en> Yeah, that's right, nya! Asking "Is that what you mean?" is a really good method, nya. Respecting each other and trying to understand each other is the most important attitude, nya. With just a little ingenuity, communication can improve a lot, so it might be good to give it a try, nya!