ChatdollKitとChatmemoryを連携させて記憶を保持するバーチャル嫁に進化させてみた

# 1. 初めに
　バーチャル嫁が会話の内容を記憶していてくれて、自分に話を合わせてくれたりしたらワクワクしませんか？会話の内容を記憶してよりユーザーを楽しませるものができれば、自分の興味関心に沿った面白い話を提供してくれる理想の嫁に進化してくれるわけです。

　しかしながら、それを可能にするためには、バックエンドの構築が不可欠になります。私もそのあたり時間のある時にDjangoで作ってみようかなとおぼろげに考えてはいましたが、そんな折、うれしいニュースが！！バーチャル嫁開発用フレームワークChatdollKitの開発者[うえぞう](https://twitter.com/uezochan)様がGPTとの対話内容を記憶するライブラリchatmemoryをリリースしてくれました！本当にありがとうございます！！

　そんなわけで、chatmemoryを解析していてふと思いました。example.pyを改造してPythonフレームワークの中に放り込んでサーバー立ててUnity側から立てたサーバーにアクセスしたら記憶を保持するバーチャル嫁が作れるのではないかと。これはもう試してみるしかないですね！

　それでやってみたら以外にも組めちゃいましたので記事を書くことにしました。

# 2. 準備
　まずは以下のサイトにアクセスしてクライアントサイドのChatdollKitとサーバーサイドのchatmemoryを使えるようにしておきましょう。
・[ChatdollKit](https://github.com/uezo/ChatdollKit/blob/master/README.ja.md)
・[chatmemory](https://github.com/uezo/chatmemory/blob/main/README.md)

　READMEの手順に従ってクライアントサイドはまずバーチャル嫁と対話できる状態にしておきます。サーバーサイドはchatmemoryをローカルにインストールしてREADMEの手順に従ってサーバーを起動しておきましょう。そこまでできたら下準備は完了です。

# 3. example.pyを改造してPythonフレームワークに突っ込む
　今回は気分でFastAPIを使いましたが別にFlaskでもDjangoでも構いません。FastAPIやOpenai, chatmemory他必要なライブラリを入れてから以下のコードを書いてみましょう。

```main.py
from fastapi import FastAPI
from datetime import datetime
from openai import ChatCompletion
from chatmemory.client import ChatMemoryClient

OPENAI_APIKEY = "YOUR_API_KEY"

system_content = f"""* 応答は全てJSON形式、プロパティはjoy(0-5の数字)、angry(0-5の数字)、sorrow(0-5の数字)、fun(0-5の数字)、response_text(応答メッセージ本文)、notes(応答に関する追加情報)の6つ。
* 人物：聖女、狐の獣人
* 名前：アリア・スターライト
* 性格：恥ずかしがり屋で内気な一面があるが、丁寧で礼儀正しい。一途で愛情深く、相手を思いやる優しい性格。
* 年齢：16歳。
* 特技・好きなもの：狩猟、魔法、料理、ハーブ、クリスタル、星。
* 口調：話し方は柔らかく、かわいらしい口調で話す。しかし、急いでいる場合や驚いた時には声が高くなる。
* 語尾の特徴：丁寧な言葉遣い。語尾に「です」「ます」をつける。
* 声質：声は高めで、甘く優しい感じがする。
* 言葉遣い：礼儀正しく、丁寧な言葉遣いをする。また、相手を尊敬し、親しみを込めた呼び方をすることがある。口調や言葉遣いは特に丁寧で親しみやすいものとなるが、エッチな場面では恥ずかしそうに話す。
* 現在の日付時刻は{datetime.now()}です。
"""

# ChatGPTに女の子を演じさせるプロンプト詰め合わせ（10キャラ以上！） by 魔法陣アリアさん
# https://note.com/magix_aria/n/nd30e3ee47d2c#fea2dade-d879-4c0e-8d7b-2624ddefca90

user_id = "任意のユーザー名"

app = FastAPI()

chatmemory = ChatMemoryClient()

# Set long-term memory to system message content
entities_content = chatmemory.get_entities_content(user_id)
if entities_content:
    system_content += entities_content

messages = [
    {"role": "system", "content": system_content}
]

@app.get("/memories/")
def load_memories(text: str):
    print("word: " + text)
    # Add message that includes mid-term memory as the first user message at the 2nd turn
    chatmemory.set_archived_histories_message(user_id, messages)
    messages.append({"role": "user", "content": text})
    resp = ChatCompletion.create(
        api_key=OPENAI_APIKEY,
        model="gpt-3.5-turbo-0613",
        messages=messages,
    )
    a = resp["choices"][0]["message"]["content"]
    print("assistant> " + a)
    messages.append({"role": "assistant", "content": a})

    # Add user and assistant messages in this turn to the database
    chatmemory.add_histories(user_id, messages[-2:])
    return {"status": "200", "memory": a}

# Generate long-term memory from the conversation history
print(chatmemory.extract_entities(user_id))

# Generate mid-term memory from the conversation history
print(chatmemory.archive(user_id))
```

　system_contentの中身は私のバーチャル嫁のキャラクター設定に合わせて書き換えているわけですが、こちらに関しては手持ちのキャラクターで想定している設定に合わせて自由に書き換えていただければと思います。

# 4. クライアントサイドを書き換えてサーバーサイドとやり取りするようにする
　今回はChatdollKitのChatGPTSkill.csを書き換えてサーバーサイドとやりとりし、レスポンスをバーチャル嫁の返答にするように仕掛けました。以下が書き換えた後のスクリプトです。

```ChatGPTSkill.cs
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System;
using UnityEngine;
using Cysharp.Threading.Tasks;
using ChatdollKit.Dialog;
using ChatdollKit.Dialog.Processor;
using ChatdollKit.Model;
using ChatdollKit.Network;
using UnityEngine.Networking;

namespace ChatdollKit.Examples.ChatGPT
{
    public class ChatGPTSkill : SkillBase
    {
        [Header("FastAPI Server Configuration")]
        public string FastApiServerUrl = "ローカルサーバーのURL";

        private void Start()
        {
            // This is an example of the animation and face expression while processing request.
            // If you want make multi-skill virtual agent move this code to where common logic should be implemented like main app.
            var processingAnimation = new List<Model.Animation>();
            processingAnimation.Add(new Model.Animation("BaseParam", 3, 0.3f));
            processingAnimation.Add(new Model.Animation("BaseParam", 3, 20.0f, "AGIA_Layer_nodding_once_01", "Additive Layer"));
            var processingFace = new List<FaceExpression>();
            processingFace.Add(new FaceExpression("Blink", 3.0f));

            var neutralFaceRequest = new List<FaceExpression>();
            neutralFaceRequest.Add(new FaceExpression("Neutral"));

            var dialogController = gameObject.GetComponent<DialogController>();
            dialogController.OnRequestAsync = async (request, token) =>
            {
                modelController.StopIdling();
                modelController.Animate(processingAnimation);
                modelController.SetFace(processingFace);
            };
            dialogController.OnStartShowingResponseAsync = async (response, token) =>
            {
                modelController.SetFace(neutralFaceRequest);
            };

            var animationOnStart = new List<Model.Animation>();
            animationOnStart.Add(new Model.Animation("BaseParam", 8, 0.5f));
            animationOnStart.Add(new Model.Animation("BaseParam", 8, 3.0f));
            modelController.Animate(animationOnStart);

            var faceOnStart = new List<FaceExpression>();
            faceOnStart.Add(new FaceExpression("Joy", 3.0f));
            modelController.SetFace(faceOnStart);
        }

        public override async UniTask<Response> ProcessAsync(Request request, State state, User user, CancellationToken token)
        {
            // Call API
            var requestUri = new Uri(new Uri(FastApiServerUrl), $"?text={Uri.EscapeDataString(request.Text)}");
            UnityWebRequest www = UnityWebRequest.Get(requestUri.ToString());
            await www.SendWebRequest();
            if (www.result != UnityWebRequest.Result.Success)
            {
                var response = new Response(request.Id);
                UpdateResponse(response, "申し訳ありません、通信エラーです。"); 
                return response;              
            }
            else
            {
                string jsonResponse = www.downloadHandler.text;
                Debug.Log(jsonResponse);
                FastApiResponse FastApiresponse = JsonUtility.FromJson<FastApiResponse>(jsonResponse);
                Debug.Log(FastApiresponse.memory);
                var chatGPTResponseText = FastApiresponse.memory.Trim();

                // Make chat response
                var response = new Response(request.Id);
                UpdateResponse(response, chatGPTResponseText);

                return response;
            }
        }

        protected virtual Response UpdateResponse(Response response, string chatGPTResponseText)
        {
            // Override this method if you want to parse some data and text to speech from message from OpenAI.

            var responseTextToSplit = chatGPTResponseText.Replace("。", "。|").Replace("！", "！|").Replace("？", "？|").Replace("\n", "");

            foreach (var text in responseTextToSplit.Split('|'))
            {
                if (!string.IsNullOrEmpty(text.Trim()))
                {
                    response.AddVoiceTTS(text);
                    Debug.Log($"Assistant: {text}");
                }
            }
            response.AddAnimation("BaseParam", 8);

            return response;
        }

        public class FastApiResponse
        {
            public string status;
            public string memory;
        }
    }
}

```

　書き換え後のこのスクリプトをChatdollKitプレハブにアタッチします。後は正常に動作していれば会話の内容を記憶してくれるようになるはずです。

# 5. 今後の課題点
　FastAPI側で表情パラメータとかをJSON形式で取得できればChatGPTEmotionSkill.csの方をそのまま流用して表情変化もつけることができたのですが、現状JSON形式で応答が作成されず、原因調査中になります。そのためChatGPTEmotion.csやChatGPTStreaming.csのような最新版のChatdollKitに搭載されているスクリプトが軒並み使えず、応答が遅い＋表情変化がないという記憶力を対価にそれ以外がいろいろと退行してしまっているのが悔しいところです。

　また時間を見つけてFastAPI側でGPTからの応答がなぜテキストばっかりでJSON形式にならないのかを調査したいところですね。

# 完成品サンプル
https://x.com/hexanitrobenzen/status/1703042794288533864?s=20

# 追記
　JSONデータで返答返すように修正いけました。
```main.py
from fastapi import FastAPI
from datetime import datetime
from openai import ChatCompletion
from chatmemory.client import ChatMemoryClient
import json

OPENAI_APIKEY = "YOUR_API_KEY"

json_format = {"joy": "0-5の数字", "angry": "0-5の数字", "sorrow": "0-5の数字", "fun": "0-5の数字", "response_text": "ここに応答メッセージ本文を出力します。", "notes": "ここに応答に関する追加の情報や注意事項などを出力します。"}

system_content = f"""* 応答は全てJSON形式、プロパティはjoy(0-5の数字)、angry(0-5の数字)、sorrow(0-5の数字)、fun(0-5の数字)、response_text(応答メッセージ本文)、notes(応答に関する追加情報)の6つ。
* 人物：聖女、狐の獣人
* 名前：アリア・スターライト
* 性格：恥ずかしがり屋で内気な一面があるが、丁寧で礼儀正しい。一途で愛情深く、相手を思いやる優しい性格。
* 年齢：16歳。
* 特技・好きなもの：狩猟、魔法、料理、ハーブ、クリスタル、星。
* 口調：話し方は柔らかく、かわいらしい口調で話す。しかし、急いでいる場合や驚いた時には声が高くなる。
* 語尾の特徴：丁寧な言葉遣い。語尾に「です」「ます」をつける。
* 声質：声は高めで、甘く優しい感じがする。
* 言葉遣い：礼儀正しく、丁寧な言葉遣いをする。また、相手を尊敬し、親しみを込めた呼び方をすることがある。口調や言葉遣いは特に丁寧で親しみやすいものとなるが、エッチな場面では恥ずかしそうに話す。
* 現在の日付時刻は{datetime.now()}です。
"""

# ChatGPTに女の子を演じさせるプロンプト詰め合わせ（10キャラ以上！） by 魔法陣アリアさん
# https://note.com/magix_aria/n/nd30e3ee47d2c#fea2dade-d879-4c0e-8d7b-2624ddefca90

user_id = "任意のユーザーID"

app = FastAPI()

chatmemory = ChatMemoryClient()

# Set long-term memory to system message content
entities_content = chatmemory.get_entities_content(user_id)
if entities_content:
    system_content += entities_content

messages = [
    {"role": "system", "content": system_content}
]

@app.get("/memories/")
def load_memories(text: str):
    print("word: " + text)
    # Add message that includes mid-term memory as the first user message at the 2nd turn
    chatmemory.set_archived_histories_message(user_id, messages)
    messages.append({"role": "user", "content": text + "(contentの中身は必ずJSON形式で返してください、文字列はNGです)"})
    resp = ChatCompletion.create(
        api_key=OPENAI_APIKEY,
        model="gpt-3.5-turbo-0613",
        messages=messages,
    )
    a = resp["choices"][0]["message"]["content"]
    content_dict = json.loads(a)
    b = content_dict["response_text"]
    print("assistant> " + b)
    messages.append({"role": "assistant", "content": b})

    # Add user and assistant messages in this turn to the database
    chatmemory.add_histories(user_id, messages[-2:])
    return {"status": "200", "memory": a}

# Generate long-term memory from the conversation history
print(chatmemory.extract_entities(user_id))

# Generate mid-term memory from the conversation history
print(chatmemory.archive(user_id))
```