🤖

Zenn執筆環境にサポートChatBotを追加する

2024/02/06に公開

やったこと

以前作成したZennの執筆環境に、記事の相談にのってくれたり、レビューをしてくれるChatBotを追加しました。
ChatBotは、OpenAIのChatGPTを使って、Streamlitで作成しました。

Zennの執筆環境の開発コンテナを立ち上げるとChatBotも立ち上がるようにし、執筆時に余計な手間なく使えるようにしています。

背景

私の性格的な部分も大きいのですが、Zennの記事を書いていると、「これで伝わるかな?」とか「この言葉は適切かな?」とか不安になることが多いです。しかし個人ブログですので、事前に誰かに相談にのってもらうというのも、なかなか難しいです。
OpenAIが提供しているChatGPTを使えば一応できるのですが、都度プロンプトを入力したり、記事の内容をコピペして投げるのも面倒だったので、以前構築した執筆環境へのChatBot追加を試してみました。

出来上がったもの

以下の2つの機能を持つChatBotが出来上がりました。

  1. 執筆支援
    • 記事を書く相談相手になってくれる
    • GraphVizを使って作図できる
  2. レビュー
    • 書いた記事のレビューをしてくれる
    • ついでに記事のタイトルと絵文字を考えてくれる

2つの機能とは言ってみたものの、違うのはほぼプロンプトの内容だけです。

以下の画像のような感じで使用できます。

執筆支援モード動作サンプル
執筆支援モード

レビューモード動作サンプル
レビューモード

GraphVizのサンプル
作図のサンプル

ChatBotはゼンマイちゃんという名前で、アイコンも作りました。
名前の由来は以下の通りです。


Zenn-MAI(ゼンマイ): Zenn Mentor and Advisor for Innovation
Zennの記事執筆を支援するメンターであり、革新的なアイデアを提供するアドバイザーとして活躍するチャットボット


Zenn-MAI(ゼンマイ)ちゃん


アイコンは、役割や名前などのイメージを伝えて、Copilotに描いてもらいました。
自分みたいに絵心のない人間でも、こんなに簡単にいい感じのアイコンを作れるのはすごい助かります。

実装について

ソースコードはここに保存してあります。
簡単にですが動かし方も書いていますので、ローカルで動かしてみることもできます。
(要OpenAIのAPIキー)
興味のある方は参照してみてください。

https://github.com/0msys/zenn-content-with-zenn-mai

以下に大まかな実装について説明します。

構成

執筆環境の開発コンテナの起動と同時にChatBotが起動するように、以下のようなフォルダ構成・設定を行いました。

フォルダ構成
zenn-content
├── .devcontainer
│   └── devcontainer.json
├── .vscode
│   └── settings.json
├── compose-dev.yml   ← Zennの執筆環境を起動すると同時に、zenn-maiも起動するようにする
├── Dockerfile
├── .gitignore
├── README.md
├── package.json
├── package-lock.json
├── node_modules
├── articles/   ← zenn-maiから参照できるようにする
├── books/      ← zenn-maiから参照できるようにする
└── zenn-mai    ← zenn-maiのコードや開発環境をまとめる
    ├── .devcontainer
    │   └── devcontainer.json
    ├── .vscode
    │   └── settings.json
    ├── .streamlit
    │   ├── config.toml
    │   └── secrets.toml
    ├── compose-dev.yml
    ├── Dockerfile
    ├── .gitignore
    ├── README.md
    ├── requirements.txt
    └── src/ 

まずZennの執筆環境用のdevcontainer.jsonですが、
"dockerComposeFile"compose-dev.ymlを指定しておきます。

.devcontainer/devcontainer.json
{
  "name": "zenn-devcontainer",
  "service": "zenn-devcontainer",
  "workspaceFolder": "/workspace",
  "dockerComposeFile": "../compose-dev.yml",
  "remoteUser": "node",
  "customizations": {
    "vscode": {
      "extensions": [
        "mhutchie.git-graph",
        "yzhang.markdown-all-in-one"
      ]
    }
  }
}

指定したcompose-dev.ymlは、Zennの執筆環境を起動すると同時に、zenn-maiも起動するようにします。
また、両コンテナともvolumesでzenn-contentのフォルダ以下をマウントしておきます。

compose-dev.yml
version: "3.9"
services:
  zenn-devcontainer:  # Zennの執筆環境
    container_name: zenn-dev 
    build:
      context: .
      dockerfile: Dockerfile
    tty: true
    volumes:
      - ./:/workspace
    ports:
      - 50000:8000  # Zennのプレビューのポート

  zenn-mai:  # zenn-maiの起動
    container_name: zenn-mai
    build:
      context: .
      dockerfile: ./zenn-mai/Dockerfile  # zenn-maiのDockerfileを指定
      target: prd # targetをprdに指定して、Streamlitの起動まで行う
    tty: true
    volumes:
      - ./:/workspace # zenn-maiのフォルダだけでなく、Zennの執筆環境のフォルダもマウントし、zenn-maiから記事が参照できるようにする
    ports:
      - 50001:8501  # Streamlit(zenn-mai)のポート

Zenn執筆環境のDockerfileは、以前作ったものと変更有りません。

Dockerfile
FROM node:18

RUN apt update && apt install -y less man-db sudo

ARG USERNAME=node
RUN echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
    && chmod 0440 /etc/sudoers.d/$USERNAME

ENV DEVCONTAINER=true

zenn-maiのDockerfileは以下のようにしました。
これでprdまでビルドすると、Streamlitの起動まで行われます。

zenn-mai/Dockerfile
FROM python:latest as base  # pythonのイメージをベースにpyuserを作成してパスを通す

ARG USERNAME=pyuser
ARG USER_UID=1000
ARG USER_GID=$USER_UID

RUN groupadd --gid $USER_GID $USERNAME \
    && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME

ENV PYTHONUSERBASE=/home/$USERNAME/.local
ENV PATH=$PYTHONUSERBASE/bin:$PATH

USER $USERNAME

WORKDIR /workspace/zenn-mai

RUN pip install --user --upgrade pip && \
    pip install --user --upgrade setuptools

FROM base as dev

USER root

RUN apt-get update \
    && apt-get install -y sudo \
    && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
    && chmod 0440 /etc/sudoers.d/$USERNAME

USER $USERNAME

CMD [ "bash" ]

FROM base as prd

USER $USERNAME

COPY . /workspace # zenn-maiのフォルダだけでなく、Zennの執筆環境のフォルダごとコピーする
RUN pip install --user -r requirements.txt

CMD ["streamlit", "run", "src/Home.py", "--server.port", "8501"]

devはゼンマイちゃんを開発するときに使うイメージで、ゼンマイちゃん自体のコードを修正するときは、開発コンテナでこのイメージをビルドして使います。
それ用のdevecontainer.jsonとcompose-dev.ymlも用意しておきます。

zenn-mai/.devcontainer/devcontainer.json
{
  "name": "zenn-mai-container",
  "service": "zenn-mai-dev",
  "workspaceFolder": "/workspace/zenn-mai",
  "dockerComposeFile": "../compose-dev.yml",
  "remoteUser": "pyuser",
  "customizations": {
    "vscode": {
      "extensions": [
        "ms-python.python",
        "ms-python.black-formatter",
        "ms-python.vscode-pylance",
        "ryu1kn.partial-diff",
        "janisdd.vscode-edit-csv",
        "mhutchie.git-graph",
        "yzhang.markdown-all-in-one"
      ]
    }
  },
  "postStartCommand": "pip install -r requirements.txt"
}
zenn-mai/compose-dev.yml
version: "3.9"
services:
  zenn-mai-dev
    container_name: zenn-mai-dev
    build:
      context: ../
      dockerfile: ./zenn-mai/Dockerfile
      target: dev
    tty: true
    volumes:
      - ../:/workspace

これで、Zennの執筆環境を起動すると同時に、zenn-maiも起動するようになりました。
http://localhost:50000 でZennのプレビューが見れますし、http://localhost:50001 でゼンマイちゃんのStreamlitが見れます。

また、/zenn-maiのフォルダを直接開発コンテナとして開くと、ゼンマイちゃんの開発環境が起動します。

ChatBotの実装

ChatBotのベース

Streamlitのドキュメントにあるコードをベースにさせてもらいました。

以下のページの一番下の方のコードをそのまま持ってくれば、ストリーミング表示に対応したチャットボットが作れます。
たったこれだけのコードで簡単なChatBotが作れちゃうので、やはりStreamlitは神ですね。

https://docs.streamlit.io/knowledge-base/tutorials/build-conversational-apps#build-a-chatgpt-like-app

取得したOpenAIのAPIキーをsecrets.tomlに書きます。

zenn-mai/.streamlit/secrets.toml
OPENAI_API_KEY = "sk-xxxxxxxxxxxxxxxxxxxxxxxx"

これでStreamlitを起動すると、チャットボットが起動します。

streamlit run src/Home.py

ゼンマイちゃんとしての機能実装

全部説明すると長大になるので、キモの部分だけ抜粋します。

まずはプロンプト部分です。

zenn-mai/src/Home.py
st.radio(
    "モードを変更する", ["執筆支援", "レビュー"], key="mode", on_change=all_reset
)

# システムプロンプトの用意
system_prompt = ""
prompt_path_dict = {
    "執筆支援": "src/prompts/support.md",
    "レビュー": "src/prompts/review.md",
}

# 記事を指定する
article = st.text_input(
    "記事のslug(ファイル名)を入力してください", key="slug", on_change=all_reset
).replace(".md", "")

if article:
    # 記事の存在確認
    if not os.path.exists(f"../articles/{article}.md"):
        st.error("指定された記事は存在しません")
        st.stop()

    # 記事を読み込む
    if st.button(
        "記事を読み込む",
        help="ファイルを更新を反映したい場合は再読み込みしてください",
        on_click=all_reset,
    ):
        with open(f"../articles/{article}.md", "r") as file:
            st.session_state.article_text = file.read()

    # 記事の読み込みチェック
    if not st.session_state.get("article_text"):
        st.error("記事を読み込んでください")
        st.stop()

    # 記事のタイトルを表示する
    title = (
        re.search(r"title: .*", st.session_state.article_text, re.MULTILINE)
        .group(0)
        .replace("title: ", "")
        .strip('" ')
    )
    st.caption(f"タイトル: {title}")

    # 記事のToken数を表示する
    encoding: Encoding = tiktoken.encoding_for_model(st.session_state.openai_model)
    token_count = len(encoding.encode(st.session_state.article_text))
    st.caption(f"Token数: {token_count}")

    # src/pronpts/にあるプロンプトファイルを読み込む
    with open(prompt_path_dict[st.session_state.mode], "r") as file:
        system_prompt = file.read()

    # system_prompt内の<ARTICLE>を記事の内容に置き換える
    system_prompt = system_prompt.replace(
        "<ARTICLE>", st.session_state.article_text
    )

モードによって読み込むプロンプトファイルを切り替えて、記事の内容をプロンプトに埋め込んでいます。
プロンプトはソースコードとは別にファイルにしておき、それを読み込んでいます。
(その方が見やすいし編集しやすいため)

記事の指定はslugにするかタイトルにするかや、セレクトボックスで選ぶようにするかは悩んだのですが、結局今書いてる記事のslugをコピペするのが一番手っ取り早いと思ったので、テキストボックスにしました。

また、チャットをしながらファイルの編集が行われることもあると思ったので、記事の読み込みボタンを設けて、再読み込みできるようにしました。

次にChatGPTのAPIを叩く部分はベースのコードからほぼ変化がないのですが、レビューモードの場合は、チャットの入力から開始せず、「レビューを開始する」ボタンを押すと、「レビューをお願いします」とAPIに投げるようにしています。

zenn-mai/src/Home.py
# レビューモードでは「レビューを開始する」を押すまで進まない
if st.session_state.mode == "レビュー":
    st.button("レビューを開始する", key="review_start", on_click=chat_reset)
    if (
        not st.session_state.review_start
        and not st.session_state.review_finished
    ):
        st.stop()

# メッセージを成形した形ではなく、そのまま表示するかどうかを選択する
st.toggle("チャットのRaw表示", key="show_raw_content")

# ユーザーの入力を受け付ける
if (
    st.chat_input("何か質問はありますか?", key="chat_input")
    or st.session_state.review_start
):
    prompt = (
        "レビューをお願いします"
        if st.session_state.review_start
        else st.session_state.chat_input
    )

最後に、GraphVizを描画する部分です。
StreamlitにはGraphVizを描画するためのメソッドが用意されているので、コードブロックとそれ以外を分割して、GraphVizのコードブロックの場合はst.graphviz_chartを使って描画するようにしました。

ただ、GraphVizのコード自体を取得したい場合に、困らないように、show_raw_contentというトグルを用意して、それによって表示を切り替えられるようにしました。

zenn-mai/src/Home.py
@st.cache_data
def content_preview(content, show_raw_content):
    # コードブロックを検出する正規表現
    code_block_pattern = re.compile(r"```[\s\S]*?```")

    # コードブロックを抽出
    code_blocks = code_block_pattern.findall(content)

    # コードブロックを置き換えるための一時的なマーカーを生成
    code_marker = "<CODE_BLOCK>"
    marked_text = code_block_pattern.sub(code_marker, content)

    # コードブロックを含まない文章のリストを取得
    text_parts = marked_text.split(code_marker)

    # コードブロックと文章を元の順番通りに結合
    combined_list = []
    for text_part, code_block in zip(text_parts, code_blocks):
        combined_list.append(text_part)
        combined_list.append(code_block)

    # 最後の文章を追加
    combined_list.append(text_parts[-1])

    for part in combined_list:
        if not part:
            continue
        # 先頭行で特定のコードブロックを検出
        if part.startswith("```dot") or part.startswith("```graphviz"):
            if show_raw_content:
                st.code(part)
            else:
                st.graphviz_chart(
                    part.strip("```").replace("dot", "").replace("graphviz", "")
                )
        else:
            if show_raw_content:
                st.code(part)
            else:
                st.markdown(part)

Raw表示のサンプル
Raw表示のサンプル

まとめ

とりあえず作りたかったChatBotが作れたので、満足しています。
プロンプトにメンターとしての役割を埋め込むことで、都度都度ほめてくれるような感じになっているのですが、これが意外と励みになります。

ChatGPTを直接使う場合に対してのメリット・デメリットは以下の通りです。

  • メリット

    • プロンプトや記事のコピペが不要
    • GraphVizの描画ができる
    • Raw表示が有るのでコピーしやすい
    • 最新のモデルが使える
    • 従量課金なので、有料版でGPT-4を使うよりも安く済む可能性がある(使用量による)
  • デメリット

    • 有料
    • 従量課金なので、有料版のGPT-4を使うよりも高くつく可能性がある(使用量による)
    • ChatGPTほど多機能ではない

やはり料金がネックになります。gpt-3.5は本当に安いので、あんまり問題ない気がしますが、gpt-4はちょっとためらうくらい高いです。
この辺はどれくらいの文量の記事を書くかや、更新頻度によって変わってくるので、まずは1カ月間gpt-3.5を使ってみて、20倍でも耐えられそうならgpt-4に切り替えるというのがいいかもしれません。
(やはり内容はgpt-4の方がいいですからね)

その他としては、自分で必要な機能を追加できるので、拡張性があるとも言えますが、逆に自分が頑張らない限りは機能が増えないので、そこが辛くなるかもしれません。

今のところ考えている機能としては、

  • PlantUMLの描画対応
  • 記事のセマンティック検索
  • 記事の共同執筆

ですが、いつできるかはわかりません。。。

とりあえず、今回のChatBotの実装については以上です。

書いていたら思った以上に長くまとまりのない感じになってしまいましたが、最後まで読んでいただきありがとうございました。


おまけ

この記事をレビューしてもらったら、以下のような結果が出ました。
(gpt-4-0125-previewを使用)

このレビューに対しては特に対応していません。


記事「Zenn執筆環境にサポートChatBotを追加する」について、編集者視点と技術的な観点からのレビューを行います。大変革新的な取り組みで、Zennの記事執筆をサポートするChatBotの開発について具体的な実装方法まで詳細に記載されており、読者にとってきわめて有益な内容となっています。

編集者視点でのレビュー

1. 日本語の誤りや表現の改善点

  • 全体を通して日本語の誤りはほとんど見受けられませんでした。非常に丁寧かつ分かりやすい説明がなされています。ただし、文章中に長文が多く、読み手が理解しにくい可能性のある部分が散見されます。適切な箇所で文章を分割することを検討してみてください。

2. 誤字脱字の指摘

  • 特に誤字脱字は見当たりませんでした。

3. 構成や流れの改善点

  • 全体の流れは非常にわかりやすいですが、「実装について」セクションがやや長く、読者が途中で理解を失う可能性があります。この部分をサブセクションに分けるか、「まとめ」セクションの前に簡潔な「実装の概要」セクションを設けることで、全体の理解をさらに深めることができるでしょう。

4. 画像やコードの追加・修正の提案

  • コードブロックについて、各コードの簡単な説明やコメントを追加することで、読者がコードの意図や役割をより理解しやすくなるでしょう。
  • 「ChatBotの実装」セクションに、実際のChatBotインターフェースのスクリーンショットを追加すると、読者が機能を視覚的に把握しやすくなります。

5. その他、記事の内容に関するアドバイス

  • 技術的な詳細が充実している一方で、非技術者や初学者には難しい表現も見られます。対象読者を明確にした上で、専門用語について簡単な説明を加えると良いでしょう。

技術的なレビュー

全体を通じて、技術的な説明が適切であり、具体的な実装方法が詳細に記述されています。技術的な誤りは見当たりませんでした。

まとめ

この記事は、Zennの執筆環境にChatBotを追加するプロジェクトについて、具体的な方法と実装について詳細に記述しており、非常に興味深く有益な内容でした。読者がさらに理解しやすくなるように、提案した改善点を検討してみてください。素晴らしい記事をありがとうございました!

タイトルと絵文字の提案

タイトル: 「Zennの執筆を助ける! 自作ChatBotの実装ガイド」
絵文字: 📝

このタイトルでは、記事の中心的なテーマである「執筆を助けるChatBotの実装」を強調しています。また、絵文字は記事の執筆に関連しており、内容を適切に反映しています。

Discussion