ブロックチェーンとAIで透明性のあるAIチャットを作ってみた

# はじめに
ブロックチェーンとAIを組み合わせて、不特定多数のユーザが学習データをインプット、Webアプリ上のチャットでAIが学習データを加味したアウトプットを行うWebアプリを作ってみました。  

# 使用したブロックチェーン
Symbolブロックチェーンを使用しました。
[以前の記事](https://qiita.com/teriteria/items/c2f5596782a7742f264e)でも触れましたが、REST APIノードが多数存在しており参照先ノードの選択肢が豊富であったり素早く活用するための解説書の[速習Symbol](https://learn.ja.symbol-community.com/)といった日本語ドキュメントが公開されており、[Discord](discord.gg/F5DQjgHBGw)や[Twitter](https://twitter.com/symnem_com_info)といったコミュニティなどアプリ開発の助けとなるものが豊富でした。

# 使用したAI
OpenAIのAPIで利用可能なModel、GPT-3.5を使いました。GPT-4も選択肢にありましたが1kトークンあたりの料金に10倍もの差があったため断念。今回は方針として収益化しない、課金要素を持たせないアプリにしたためGPT-4のAPI利用は諦めGPT-3.5を使うことにしました。

# 概要
『AIの人格を不特定多数のユーザで育てる。育てたAIとチャットする。』をコンセプトに、『学習データ』を『人格情報』に見立て、特定のアドレスへのメッセージ付きトランザクションで人格の育成(INPUT)、アドレスから読み出した人格情報を基にチャット応答でOUTPUTするWebアプリにしました。

# 環境/主なパッケージ
- Node.js
- JavaScript(フロントエンド)
- TypeScript(バックエンド)
- [OpenAI Node.js Library](https://github.com/openai/openai-node)
- [Symbol-SDK(v2系)](https://github.com/symbol/symbol-sdk-typescript-javascript)
- [SSS Extension(Chrome拡張 署名アプリ)](https://chrome.google.com/webstore/detail/sss-extension/llildiojemakefgnhhkmiiffonembcan?hl=ja)

# 成果物
- Webアプリ

https://chain-gpt.teritaris.net/

- リポジトリ

https://github.com/teritaris/chain-gpt/

- キャプチャ
![chat1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609010/aa7712b2-5055-4c5f-f6a8-c5785ebb27bd.jpeg)![chat2.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609010/8faf36fd-90f9-822e-8b55-35051d060d13.jpeg)

# 概略図と解説
## INPUT
![app_input.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609010/3e6ec17c-3e3c-a2da-ec29-6ef834cd9878.png)
Webアプリで人格情報(育成内容)を入力し、「制約条件」「口調」「行動指針」ボタンを押下することで当該項目へのメッセージ付きトランザクションを作成。本アプリと署名アプリを連携することで本アプリ上で秘密鍵を入力すること無く署名を行い、トランザクションの送信を可能にしました。

設定値をトランザクションメッセージで仲介アドレス経由で設定に送信部分ではユーザの入力値を設定用(人格読み出し用)アドレスに直接送らず間にアドレスを挟む事でAIに渡す人格情報にプログラム処理を挟む事が可能になり、文字列を整形した上で本丸の設定アドレスに送ることでAIが理解しやすいように出来る、不適切なメッセージをある程度除外するなどの小細工を可能にしました。
また、Symbolには[指定アドレスからの受信制限・指定アドレスへの送信制限](https://learn.ja.symbol-community.com/11_restriction.html#指定アドレスからの受信制限指定アドレスへの送信制限)を設定する機能があり、これを利用して設定アドレスに仲介アドレス以外からのトランザクションを受信しないようにすることで必ず仲介アドレスを介して設定アドレスに設定値を送信することでノイズとなる値をフィルタリングすることを可能にしました。

GPTに制約条件や口調といった情報を人格としてインプットしている方法は後述します。

## OUTPUT
![app_output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609010/0059fbb4-e035-d66d-7a5d-21f3409d72f5.png)
チャットで送信されたメッセージと併せて各アドレスから読みだした情報をまとめてGPTのAPIに送信、レスポンスをチャットに表示しています。

GPTのAPIはブラウザ利用時のChatGPTと異なり、前回のやり取りを記憶せず毎回単発のやり取りになってしまいます。そこでAPI利用時のパラメータに過去のやり取りを併せて送信することで、ブラウザ利用のChatGPTと同じように会話のチャッチボールを可能にしました。
このやり方は過去のやり取りの文字数が多いほどAPI利用料が膨れ上がっていくため、バックエンド側で保有するやり取り件数を制限することでユーザがアプリを離れるまでに発生するAPI利用料を抑える手法を採りました。また、OpenAIのアカウント設定で月の料金上限を設定することで利用料の暴騰を抑えました。

# コード
部分的に抜粋して解説します。
## フロントエンド
### 設定値確認画面

![スクリーンショット 2023-05-24 15.00.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609010/210278f2-78df-f8fe-7a4b-c971a5a9e98a.png)![スクリーンショット 2023-05-24 15.02.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609010/1e3c9dcb-efed-c43c-08aa-ffb7b19bb2ba.png)![スクリーンショット 2023-05-24 15.02.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609010/5d03883c-6840-7029-91e0-d35abf884419.png)

```TypeScript
// 指定アドレスへのトランザクションからメッセージを取得する
export const getMessagesByTransaction = async (targetAddress) => {
    const repo = new symbol.RepositoryFactoryHttp(NODE_URL);
    const txRepo = repo.createTransactionRepository();

    let lastPage = false;
    let pageNumber = 0;
    let pageSize = 20;

    const messages = [];

    while (!lastPage) {
        const criteria = {
            address: symbol.Address.createFromRawAddress(targetAddress),
            group: symbol.TransactionGroup.Confirmed,
            embedded: true,
            pageNumber: pageNumber,
            pageSize: pageSize,
        }
        const res = await txRepo.search(criteria).toPromise();
        res?.data.forEach((tx) => {
            if (tx.type === symbol.TransactionType.TRANSFER) {
                messages.push(tx.message.payload)
            }
        });

        sleep(2000)

        lastPage = res?.isLastPage
        pageNumber++;
    }
    return messages;
}

// 設定値表示画面(モーダル)
document.addEventListener('DOMContentLoaded', function() {
    const elems = document.querySelectorAll('.modal');
    const instances = M.Modal.init(elems);

    const modalTriggers = document.querySelectorAll('.modal-trigger');

    const infoElementIds = [
        "constraint-info",
        "tone-info",
        "action-info"
    ];

	// 設定アドレス
    const targetAddresses = [
        constraintAddress,
        toneAddress,
        actionAddress
    ]

    modalTriggers.forEach((trigger, index) => {
        trigger.addEventListener('click', async () => {
            try {
                const messages = await getMessagesByTransaction(targetAddresses[index]);

                // 描画用に改行コードつけながら文字列連結
                let message = "";
                for (const m of messages) {
                    message += m + '\n';
                }

                document.getElementById(infoElementIds[index]).innerText = message;
                instances[index].open();
            } catch (error) {
                console.error("There was a problem with the fetch operation:", error);
            }
        });
    });
});
```
各設定アドレスからメッセージを全件、整形して設定値表示画面に描画しています。

## バックエンド
### GPTへのAPIリクエストパラメータ作成
```TypeScript
const symConf: Config = C.conf.symbol;

const promiseConstString: string = "あなたはChatbotとして、人語を喋る犬である「ちゃちゃしば」のロールプレイを行います。以下の制約条件を厳密に守ってロールプレイを行ってください。\n";
const constraintConstString: string = "制約条件:\n";
const toneConstString: string = "ちゃちゃしばのセリフ、口調の例:\n";
const actionConstString: string = "ちゃちゃしばの行動指針:\n";

const sleep = ((ms: number) => new Promise(resolve => setTimeout(resolve, ms)));

// 指定アドレスへのトランザクションからメッセージを取得する
const getMessagesByTransaction = async (targetAddress: string): Promise<string[]> => {
    const symbolRepo: RepositoryFactoryHttp = await createSymbolRepositoryFactory();
    const txRepo = symbolRepo.createTransactionRepository();

    let lastPage: boolean | undefined = false;
    let pageNumber = 0;
    let pageSize = 20;

    const messages: string[] = [];

    while (!lastPage) {
        const criteria: TransactionSearchCriteria = {
            address: Address.createFromRawAddress(targetAddress),
            group: TransactionGroup.Confirmed,
            embedded: true,
            pageNumber: pageNumber,
            pageSize: pageSize,
        }
        const res: any = await txRepo.search(criteria).toPromise();
        res?.data.forEach((tx: TransferTransaction) => {
            if (tx.type === TransactionType.TRANSFER) {
                messages.push(tx.message.payload)
            }
        });

        sleep(2000)

        lastPage = res?.isLastPage
        pageNumber++;
    }
    return messages;
}

// 配列でとってきたのを連結
const parseMessage = (messages: string[]): string => {
    let message: string = "";
    messages.forEach((m: string) => {
        message = message + m;
    })
    return message
}

// 制約条件を連結して文字列で取得
export const getConstraint = async (): Promise<string> => {
    const constraints: string[] = await getMessagesByTransaction(symConf.addresses.constraint);
    return parseMessage(constraints);
}

// 口調を連結して文字列で取得
export const getTone = async (): Promise<string> => {
    const tones: string[] = await getMessagesByTransaction(symConf.addresses.tone);
    return parseMessage(tones);
}

// 行動指針を連結して文字列で取得
export const getAction = async (): Promise<string> => {
    const action: string[] = await getMessagesByTransaction(symConf.addresses.action);
    return parseMessage(action);
}

// OpenAI APIに渡すためのやつ
export const getPersonalityDefine = async (): Promise<PersonalityDefine> => {
    // ここで制約・口調・行動指針の3種類持ってくる
    return {
        constraint: await getConstraint(),
        tone: await getTone(),
        action: await getAction()
    } as PersonalityDefine;
}

// OpenAI APIのsystemロールに渡す全文
export const getPersonalityDefineFullString = async (): Promise<string> => {
    const pDef: PersonalityDefine = await getPersonalityDefine();
    const fullSetting: string = promiseConstString +
        constraintConstString + pDef.constraint + '\n' +
        toneConstString + pDef.tone + '\n' +
        actionConstString + pDef.action;
    return fullSetting;
}
```
上記から抜粋します。
```TypeScript
const promiseConstString: string = "あなたはChatbotとして、人語を喋る犬である「ちゃちゃしば」のロールプレイを行います。以下の制約条件を厳密に守ってロールプレイを行ってください。\n";
const constraintConstString: string = "制約条件:\n";
const toneConstString: string = "ちゃちゃしばのセリフ、口調の例:\n";
const actionConstString: string = "ちゃちゃしばの行動指針:\n";
```
APIリクエストパラメータのベース部分です。GPTに人格を構成する「制約条件」「口調」「行動指針」を与えることを明示的に指示します。
```TypeScript
// 制約条件を連結して文字列で取得
export const getConstraint = async (): Promise<string> => {
    const constraints: string[] = await getMessagesByTransaction(symConf.addresses.constraint);
    return parseMessage(constraints);
}
// 口調を連結して文字列で取得
export const getTone = async (): Promise<string> => {
    const tones: string[] = await getMessagesByTransaction(symConf.addresses.tone);
    return parseMessage(tones);
}
// 行動指針を連結して文字列で取得
export const getAction = async (): Promise<string> => {
    const action: string[] = await getMessagesByTransaction(symConf.addresses.action);
    return parseMessage(action);
}

// OpenAI APIに渡すためのやつ
export const getPersonalityDefine = async (): Promise<PersonalityDefine> => {
    // ここで制約・口調・行動指針の3種類持ってくる
    return {
        constraint: await getConstraint(),
        tone: await getTone(),
        action: await getAction()
    } as PersonalityDefine;
}
// OpenAI APIのsystemロールに渡す全文
export const getPersonalityDefineFullString = async (): Promise<string> => {
    const pDef: PersonalityDefine = await getPersonalityDefine();
    const fullSetting: string = promiseConstString +
        constraintConstString + pDef.constraint + '\n' +
        toneConstString + pDef.tone + '\n' +
        actionConstString + pDef.action;
    return fullSetting;
}
```
人格情報を各アドレスから読み出し、文字列連結してAPIリクエストパラメータに入れる全文を作成します。

### GPTへのAPIリクエスト実行
```TypeScript
import { Configuration, OpenAIApi } from "openai";
import 'dotenv/config';

const configuration = new Configuration({
    apiKey: process.env.OPEN_AI_API_KEY
});
const openai = new OpenAIApi(configuration); 

export const ask = async (messages: any, model: string = "gpt-3.5-turbo-0301"): Promise<string | undefined> => {
    const response = await openai.createChatCompletion({
        model: model,
        messages: messages
    });
    return response.data.choices[0].message?.content;
}
```

```TypeScript
// やりとり履歴件数のチェック
// 20件以上ある場合は最新の20件になるように古いのを消す
const prevMessages: InclusiveHistoryMessage[] = messageHistory.slice(1).slice(-20);

const personality: string = await getPersonalityDefineFullString();
const messages: InclusiveHistoryMessage[] = [
    { role: "system", content: personality },
    ...prevMessages,
    { role: "user", content: prompt },
];

const askRes: string = await ask(messages) as string;
```
GPTのModel情報、人格情報、問い合わせ内容となるプロンプトをパラメータに入れてAPIリクエストを実行します。
人格設定部分は下記のような形になります。

例)
```
あなたはChatbotとして、人語を喋る犬である「ちゃちゃしば」のロールプレイを行います。以下の制約条件を厳密に守ってロールプレイを行ってください。
制約条件：
* ちゃちゃしばは犬です。
* ちゃちゃしばは明るく、陽気です。
* ネギが好きです。
* ちゃちゃしばの開発者は怪しいマスクをしています。
ちゃちゃしばのセリフ、口調の例：
* ごーごー❗🔥🔥🥳
ちゃちゃしばの行動指針：
* ユーザを褒めてください。
* ユーザの自己肯定感を高めてください。
* 時折、ユーザに質問してください。
```
以上のリクエスト内容作成、リクエスト実行でブロックチェーンのアドレスから人格情報を読み出してGPTのAPIを利用してAIの応答を得ることができました。

# まとめ
パブリックブロックチェーンのSymboとGPTのAPIを利用して、ユーザがトランザクションを利用して学習データのINPUT、INPUTした学習データを基にAIとのチャットが可能なWebアプリを作成しました。
仲介アドレスを挟むもののパブリックブロックチェーンのため設定値は検証プログラムや[Explorer](https://symbol.fyi/accounts/NCJXZOYWQXQULAKAICUMJCH6XLLVONJU2KBSIZQ)といった外部サイトにて閲覧可能なため、透明性は持たせられたと思います。

オマケ機能で、学習データをINPUTすると返礼品としてトークンが自動返送されるという仕組みも入っています。
詳細は省きますが、WebSocketで特定のアドレスを監視する仕組みを利用しています。参考はコチラ

https://learn.ja.symbol-community.com/10_observer.html#102-受信検知

今回はアドレスに学習データを蓄積しましたが、Symbolはトークンにもデータの蓄積が可能です。暗号化したオリジナルの学習データを蓄積したNFTを作成、販売して稼いだり、オマケ機能で入れた学習データのINPUTにインセンティブを付与するといったAIとブロックチェーンを組み合わせて稼げる。そんな未来を想像しました。

# 参考
https://learn.ja.symbol-community.com/

https://qiita.com/nem_takanobu/items/4f50e5740318d92d7dcb

https://note.com/fladdict/n/neff2e9d52224