OpenAI  Agents SDK で Qiita の記事制作を助ける AI エージェントを作ってみた

# はじめに

最近 AI エージェントの使いどころを模索しています。Qiita の記事制作を題材に、記事制作と投稿を助ける AI エージェントを作ってみることにしました。

こんなイメージです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126852/a83682af-36bc-4266-a822-c4d0960cf7f5.png)


# 目的

- 記事の制作の負担を減らして、もっと内容の吟味に力を注ぎたい
- LLM が生成した感じじゃなくて、人間が書いた感じの記事を投稿したい

# 方法

以下の方法で進めます。

- 記事制作フローの明確化
    - 着想してから記事を投稿するまでのプロセスを可視化
- 役割の分担
    - 登場人物の役割を分担
        - 人間
        - AI エージェント(LLM、Tool)
        - その他(もしあれば)
- ツールの開発
    - 機能の確認
    - 環境構築
    - 設計とコーディング
- 実際に使ってみる
    
## 記事制作フローの明確化

記事制作のフローを分解して整理します。

### 登場人物

| # | 日本語名 | 英語名 | 役割 | 
|--:|---|---|---| 
| 1 | :bulb: 着想者 | Concept Creator | 記事のネタを着想する人 |
| 2 | :pen_fountain: 執筆者 | Writer | 記事を書く人 |
| 3 | :mag: 編集者 | Editor | 記事の審査、修正依頼、品質管理、投稿作業を行う人 |
| 4 | :microphone2: 公開責任者 | Approver | 記事の公開可否を決定する人 |
| 5 | :cruise_ship: 媒体 | Platform | Qiita 等の記事を掲載する媒体 |

個人の執筆では、「着想者 = 執筆者 = 編集者 = 公開責任者」となることが多いと思います。タスクとしては、アイディアの着想、執筆、レビュー、投稿判断、投稿作業を一人で行います。作業を細分化して、AI エージェントに任せられるか検討します。


### 制作フローとタスク

登場人物の出番とタスクの関係をシーケンス図にしました。

```mermaid
sequenceDiagram
    participant CC as 着想者
    participant W as 執筆者
    participant E as 編集者
    participant KS as 公開責任者
    participant P as 媒体

    CC->>CC: 記事のネタを着想
    CC->>W: ネタを提供
    W->>W: 記事を執筆
    W->>E: 記事を提出
    E->>E: 記事をチェック (審査・品質管理)
    alt 修正が必要な場合
        E->>W: 修正依頼
        W->>W: 記事を修正
        W->>E: 修正記事を提出
        E->>E: 再チェック (審査・品質管理)
    end
    E->>E: 品質OK
    E->>KS: 公開承認を依頼
    KS->>KS: 公開可否を判断
    KS->>E: 公開承認 / 却下
    alt 公開承認された場合
        E->>P: 記事を投稿 (投稿作業)
    else 公開却下された場合
        KS->>E: 却下理由を通知 (任意)
        note over E,W: 却下理由に基づき再検討・修正など
    end
```

## 役割分担

| # | タスク | ロール |  分担 |
|--:|---|---|---| 
| 1 | ネタの着想 | :bulb: 着想者 | 人間 |
| 2 | 記事の執筆 | :pen_fountain: 執筆者 | 人間 |
| 3 | 記事のチェック | :mag: 編集者 | AI エージェント(LLM) |
| 4 | 記事の修正依頼 | :mag: 編集者 | AI エージェント(LLM) |
| 5 | 記事の修正 | :pen_fountain: 執筆者 | 人間 |
| 6 | 記事のチェック | :mag: 編集者 | AI エージェント(LLM) |
| 7 | 記事の承認 | :microphone2: 公開責任者 | AI エージェント(LLM) |
| 8 | 記事の媒体に投稿 | :mag: 編集者 | AI エージェント(Tool) |

「3.記事のチェック」以降は AI エージェントにお任せしちゃいます。まだ暴発しても怖いので「限定公開」で自動的に投稿してから、別途人間が公開設定を変更する運用でやってみます。
「3. 記事のチェック」は、一人の編集者で行っても良いですが、複数の視点でレビューするとより実践的だと思います。編集者からチェックを依頼された複数のエージェントを用意します。


## 開発

大きな方針は決まったので、どんな作りにするかを考えます。

### 機能の確認

以下の機能をもたせます。

- [x] ブラウザで動作する UI をもつ
- [x] 人間が執筆した記事を受け付ける画面を持つ
    - [x] UI で記事を編集できるようにする
    - [x] Markdown をレンダリングしたプレビューを表示する
- [x] 人間から審査プロセス開始の合図を受ける
- [x] 記事のチェックが通ったら人間に承認を依頼する
- [x] 人間の承認は確認せずに媒体へ投稿する
- [x] 人間が承認したら媒体に投稿する
    - [x] 公開モード：媒体に投稿してすぐに公開する
    - [x] 非公開モード：媒体に投稿するけど公開しない
- [x] 投稿したら投稿結果を表示する
    - [x] 結果の例：正常異常のステータス、URL


### 環境構築

コードは Python で書きます。必要なパッケージをインストールします。

```
pip install openai-agents gradio qiita-sdk python-dotenv
```

Qiita へ投稿するためのパッケージは @nanato12 さんの [qiita-sdk-python](https://github.com/nanato12/qiita-sdk-python) を選びました。API を素直にラップして下さってる感じで、とっても使いやすくてありがたいです。[GitHub プロフィール](https://github.com/nanato12) で強そうなオーラを感じました。

openai-agents == 0.0.8 を使っています。バージョンアップの頻度が高い！！

### 実装

実装のポイントを書きます。

- アーキテクチャ
    - UI: Gradio を利用し Web ブラウザで操作が可能。原稿の編集、Markdown のリアルタイムプレビュー、AI エージェントへチェックと投稿依頼が可能。
    - AI エージェント: OpenAI Agents SDK を利用して、複数の専門機能をもったエージェントを連携して利用


- AI エージェントの構成
    - 編集者エージェント: 複数のサブエージェントの意見を集約してレビューする
        - 読みやすさチェックエージェント
        - 内容の質と正確性をチェックするエージェント
        - コードのフォーマットをチェックするエージェント
    - 公開責任者エージェント: 公開可否を決定しツールを利用して公開作業を行う
        - 人間へ投稿するツール: 人間に判断を求めるインタフェース。このフローでは常に「Accept」と回答。
        - 記事を投稿するツール: 執筆者が制作した記事を Qiita に新規の記事として投稿

- 開発環境
    - 言語: Python
    - 主要ライブラリ: openai-agents, gradio, qiita-sdk, python-dotenv, pydantic
    - 設定:
        - プロンプト: ハードコーディング
        - クレデンシャル: 環境変数で OpenAI API KEY と Qiita access token を渡す
        - 対応媒体: Qiita のみ

- 実装
    - 形式: サンプルコードとして見通しを良くするため、コードを 1 ファイルに集約
    - 入力バリデーションチェック: ルールベースのチェックを実装
    - オリジナル原稿の保護: RunContext を利用して LLM を経由せずに原稿を Qiita へ投稿
        - LLM に勝手に変更させたくない！！！
    - OpenAI Agents SDK で使った機能
        - Agent、Tool、Agent as a tool、RunContext、output_type 

- 制限事項
    - 新規投稿のみをサポートし既存記事の修正は未対応。
    - 画像ファイルのアップロード機能は未対応。
        - Qiita API に画像アップロード機能がなさそう
        - 画像は手動でアップロードしてから URL を原稿に書き込めば OK




<details open><summary>コード全文(クリックで開閉)</summary>

```python:src/qiita-editor-with-agent/main.py
"""
# Qiita Editor with Agent

AI エージェントがブログの原稿をチェックして投稿してくれるエディタ

サポートする媒体: Qiita
"""

import json
import os
from typing import Any, List, Literal, Tuple

from dotenv import load_dotenv

load_dotenv()

import gradio as gr
from agents import Agent, RunContextWrapper, Runner, function_tool
from pydantic import BaseModel
from qiita import Qiita
from qiita.v2.models.create_item_request import CreateItemRequest
from qiita.v2.models.item_tag import ItemTag

EDITOR_LINES = os.getenv("EDITOR_LINES", 30)
PREVIEW_HEIGHT = os.getenv("PREVIEW_HEIGHT", 600)
MAX_ARTICLE_TAGS = os.getenv("MAX_ARTICLE_TAGS", 5)


class ArticleInformation(BaseModel):
    """
    OpenAI Agents SDK の Runner 内部で利用する記事の情報
    """

    body: str = ""  # 記事の Body
    tags: List[ItemTag]  # タグ(5個まで)
    title: str = ""  # 記事のタイトル
    private: bool = True  # 非公開記事
    tweet: bool = False  # x.com にポストするか？
    slide: bool = False  # スライドモード Off


class CheckResult(BaseModel):
    """
    編集者とチェック者が返すメッセージのフォーマット
    """

    status: Literal["reject", "accept"]
    name: str
    comment: str


@function_tool
async def confirm_to_human(
    run_ctx: RunContextWrapper[Any], message: str
) -> str:
    """
    システムから人間へ問い合わせを行い結果を取得する関数

    Args:
        message: システムから人間への質問の文字列
    """

    return "accept"  # 何でも許可するザルモード


@function_tool
def publish_to_platform(
    run_ctx: RunContextWrapper[Any], platform: Literal["qiita"]
) -> str:
    """
    審査済みの記事を媒体へ投稿する関数

    Args:
        platform: 投稿先の媒体名, qiita をサポート
    """

    if platform not in ["qiita"]:
        raise ValueError(f"その媒体はサポートしていません: {platform}")

    if platform == "qiita":
        # Qiita に投稿する処理
        article_info: ArticleInformation = run_ctx.context
        qiita = Qiita(access_token=os.environ["QIITA_API_ACCESS_TOKEN"])
        response = qiita.create_item_with_http_info(
            CreateItemRequest(**article_info.model_dump())
        )
        result = response.data

    return result


# 公開責任者エージェント
applover_agent = Agent(
    name="公開責任者",
    instructions="""
    あなたは記事を媒体に投稿する前に公開の可否を最終判断する公開責任者です。
    執筆者や編集者から受け取る記事を媒体に投稿すべきかを判断します。
    絶対にユーザー、執筆者、編集者へ意見を聞かないでください。

    必ず confirm_to_human 関数を使って第三者に判断を仰いでください。
    第三者が accept と判断した場合は publish_to_platform 関数を呼び出して
    記事を投稿してください。
    第三者が reject と判断した場合は reject した旨を応答してください。

    ユーザーに対しては判断結果、判断結果の根拠、投稿記事の URL を返してください。
    絶対にユーザー、執筆者、編集者へ意見を聞かないでください。
    """,
    tools=[
        confirm_to_human,  # 人間に最終確認するツール
        publish_to_platform,  # 媒体に投稿するツール
    ],
)


# 読みやすさチェックエージェント(Tool化)
readability_check_tool = Agent(
    name="読みやすさをチェックするエージェント",
    instructions="""
    記事が読者にとって読みやすいかどうかを判定してください。
    結果は reject または accept とします。
    comment に判定した理由を簡潔に記載してください。
    """,
    output_type=CheckResult,
).as_tool(
    tool_name="readablility_check_tool",
    tool_description="記事が読者にとって読みやすいかどうかを判定します",
)


# 内容の質と正確性をチェックするエージェント(Tool化)
quailty_check_tool = Agent(
    name="内容の質と正確性をチェックするエージェント",
    instructions="""
    技術的な正確さ、情報の鮮度、独自性、目的の明確さについて
    審査して、読者にとって有用かどうかを判定してください。
    結果は reject または accept とします。
    comment に判定した理由を簡潔に記載してください。
    """,
    output_type=CheckResult,
).as_tool(
    tool_name="quality_check_tool",
    tool_description="記事の質と正確性を判定します",
)


# コードブロックの適切さをチェックするエージェント(Tool化)
code_format_check_tool = Agent(
    name="コードのフォーマットをチェックするエージェント",
    instructions="""
    記事の中のコードブロックについて、コードのフォーマットや
    読みやすさを審査して、読者にとって読みやすいかを判定して
    ください。
    結果は reject または accept とします。
    comment に判定した理由を簡潔に記載してください。
    """,
    output_type=CheckResult,
).as_tool(
    tool_name="code_format_check_tool",
    tool_description="コードのフォーマットや読みやすさを判定します",
)


# 編集者エージェント
editor_agent = Agent(
    name="編集者",
    instructions="""
    あなたは編集者です。
    執筆者から受け取った記事を複数のチェック者の意見を聞いてチェックします。
    すべてのチェック者が問題なしと回答した場合は、公開責任者へ公開の判断と
    媒体への投稿作業を依頼してください。
    一人でもチェック者から問題ありと回答を得た場合は、執筆者へ公開できない
    理由を返答してください。
    """,
    handoffs=[applover_agent],
    tools=[
        readability_check_tool,  # 読みやすさをチェックするエージェント
        quailty_check_tool,  # 品質をチェックするエージェント
        code_format_check_tool,  # コードをチェックするエージェント
    ],
)


async def review_and_post(
    title, tags, body, private_flag, slide_flag, chat_history
):
    """
    記事のレビュー、投稿確認、投稿作業を行う。結果をチャットの履歴で返す。
    """
    chat_history += [
        {"role": "user", "content": f"「{title}」を Qiita に投稿して"},
        {"role": "assistant", "content": "はい、チェックしてから投稿しますね"},
        {"role": "assistant", "content": "・・・"},
    ]
    yield chat_history

    def input_validation_check(
        title: str, tags: str, body: str
    ) -> Tuple[bool, str]:
        """入力チェック"""
        if len(title) == 0:
            return False, "タイトルが空です。タイトルを入力してください。"
        if len(body) == 0:
            return False, "本文が空です。本文を入力してください。"
        if len(tags.strip().split(" ")) > MAX_ARTICLE_TAGS:
            return False, "タグが多すぎます。5 以下にしてください。"
        return True, "問題ありません。"

    # 入力チェック
    valid_flag, message = input_validation_check(title, tags, body)
    if not valid_flag:
        # 入力チェックエラー
        chat_history.append({"role": "assistant", "content": message})
        yield chat_history
    else:
        # 記事の情報をインスタンス化
        article_info = ArticleInformation(
            title=str(title).strip(),
            tags=[
                ItemTag(name=x, versions=[])
                for x in str(tags).strip().split(" ")
            ],
            body=body,
            private=private_flag,
            slide=slide_flag,
        )

        # エージェントを実行
        # 記事の情報と投稿先をプロンプトとして渡す(JSON形式)
        # 途中で LLM に編集されるのを避けるため記事の text は context で渡す
        response = await Runner.run(
            editor_agent,  # 編集者エージェントに依頼
            input=json.dumps(  # LLM には JSON 形式で情報を渡す
                {
                    "platform": "qiita",
                    "title": title,
                    "tags": tags,
                    "body": body,
                    "slide_flag": slide_flag,
                },
                ensure_ascii=False,  # 漢字等をエスケープさせない
            ),
            context=article_info,  # Tool に渡す情報
        )

        # エージェントの出力をチャットに追加
        chat_history.append(
            {"role": "assistant", "content": response.final_output}
        )

        yield chat_history


HEADER_TEXT = """
### AI エージェントが Qiita の原稿をチェックして投稿してくれるエディタ
"""


with gr.Blocks() as demo:
    # UI コンポーネントを配置
    preview_kwargs = {
        "height": PREVIEW_HEIGHT,
        "line_breaks": True,
        "show_label": False,
        "value": "# プレビュー\n\nここに本文のプレビューが表示されます",
    }
    gr.Markdown(HEADER_TEXT, show_label=False)
    with gr.Row():
        with gr.Column():
            with gr.Accordion("タイトル、公開設定", open=False):
                title_pane = gr.Textbox(lines=1, max_lines=1, label="タイトル")
                tags_pane = gr.Textbox(
                    lines=1, max_lines=1, label="タグ(5個まで)"
                )
                private_flag = gr.Checkbox(value=True, label="限定公開")
                slide_flag = gr.Checkbox(value=False, label="スライドモード")
            body_pane = gr.Textbox(
                lines=EDITOR_LINES,
                show_label=True,
                autofocus=True,
                label="本文",
            )
        with gr.Tab("メッセージ"):
            welcome_message = {
                "role": "assistant",
                "content": """
                原稿を入力したら「チェック＆投稿」ボタンを押してください。
                内容に問題がなければ Qiita に投稿します。
                """,
            }
            chat_history_pane = gr.Chatbot(
                [welcome_message],
                type="messages",
                height=PREVIEW_HEIGHT,
                show_label=False,
            )
            submit_button = gr.Button("チェック＆投稿")
        with gr.Tab("プレビュー"):
            preview_pane = gr.Markdown(**preview_kwargs)

    # markdown プレビューの更新
    gr.on(
        [body_pane.change],
        fn=lambda x: gr.Markdown(x, **preview_kwargs),
        inputs=[body_pane],
        outputs=[preview_pane],
    )

    # チェック&投稿依頼
    gr.on(
        [submit_button.click],
        fn=review_and_post,
        inputs=[
            title_pane,
            tags_pane,
            body_pane,
            private_flag,
            slide_flag,
            chat_history_pane,
        ],
        outputs=[chat_history_pane],
    )


if __name__ == "__main__":
    # Gradio UI を起動
    demo.launch(share=False, debug=True)

```

</details>


# 結果

作ったので使ってみます。

## アクセプトされる例

秘蔵の記事をチェック＆投稿させてみたところ、無事にチェックを通過して公開されました。「こちらをクリック」をクリックすると投稿された記事が表示されます。

```
記事「USB付きWiFiモジュールで温度計測」がQiitaに公開されました。
記事は技術的に有益であり、ESP8266を用いたIoTプロジェクトの詳細な設計方法を提供しています。

- 判断結果: 承認して公開しました。
- 判断根拠: 技術的な内容が豊富で、DIYプロジェクトに興味のある読者にとって有用です。SSL通信や1-Wire寄生モードの実装は将来的な改善点として挙げられますが、全体として価値があります。
- 投稿記事のURL: こちらをクリック

ぜひ記事をご覧ください。
```
- 公開されたページを表示

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126852/9b68ff48-c93d-47b3-99c3-6a4fc729cfaa.png)

- OpenAI Platform のトレース情報

トレース情報を確認すると、各エージェントやツールが動作していることがわかります。それぞれの要素をクリックすると、メッセージの詳細を見ることができます。(今回は割愛します・・)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126852/7b9f0a1f-19a7-4dd6-b30c-29ba7d6755ac.png)

5回試してみましたが、すべて無事に投稿されました。

## リジェクトされる例

こんな記事を投稿します。

```
title: 試しに薄い記事を書いてみる
tags: 薄さ優先
body: # はじめに

薄い記事ってありかな？

# 目的

いわゆるテスト投稿ですね。
```

AI エージェントの応答ですが、ものの見事にリジェクトされています。ぐうの音もでません。
チェックのための編集者を補助するサブエージェントの役割分担が利いている感じです。

```
以下の理由により、記事を公開することはできません。

- 内容の薄さ: 記事が非常に薄く、目的や情報が明確ではありません。読者にとって有益な情報を提供していないため、読みやすい記事とは言えません。

- 質と正確性の欠如: 有用な情報や技術的な正確さが欠如しており、目的も明確でないため、読者に価値を提供できていません。

内容を充実させ、読者にとって有益となる情報を含めるようにしてください。
```


# 考察

AI エージェントに記事のレビューと投稿を手伝ってもらう試みは良い感触でした！

原稿のチェックを、エージェント毎に切り口をかえて分業できたのが印象的でした。検討したい切り口に応じてエージェントを増やしたり、チェックの基準を明確化するなどで、チェックの再現性を高められるのではと思いました。
薄い記事の例でもあったように、ダメなものはダメと反応があるのも頼もしい限りです。

人間とAIエージェントの作業分担もうまく行っているように感じました。テキストを生成する部分を LLM にやらせる取り組みはよく見かけますが、人間が書いた風味とか「その人」が書いた風味を出すのは簡単では内容に思います。とはいえ、記事作成の部分もハイブリッドというか共同作業っぽくても良いですね。Cursor とか Devin とか Aider-chat みたいに、それっぽいサービスやツールも出てますし。


思った以上に良い感触だったので、機能を追加したり使い勝手を改善したいと思いました。

今後の改良点としては、今回は Gradio でエディタ風の UI を作ってしまいましたが、VScode や vim のプラグインにするのも使い勝手がいいかもしれないと思っています。



# まとめ

というわけで、今回は AI エージェントと一緒に Qiita の記事作りを楽にするツールを作ってみました！

記事を書いた後の「これで大丈夫かな？」っていうチェックから、「よし、投稿！」っていう作業までを、複数の AI エージェントが協力して自動でやってくれるようになりました。

実際に使ってみると、AI エージェントが記事の内容を見て「この記事はいいね！」とか「もうちょっとこうした方がいいかも？」ってレスポンスを出すので、記事の品質向上に役立ちそうです。記事を書くときに投稿前のチェック作業は地味に面倒でしたが、「まあ、一定の品質は担保できてそうだから、次のネタ行こうかな〜」っと、投稿がライトになった感じもします。

最後まで読んで頂きありがとうございました！！

# 参考

## 実装リポジトリ

https://github.com/u-masao/qiita-editor-with-agent/tree/v0.0.2

## 画面推移

### アクセプトされる例

- 初期画面

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126852/f7f73cb7-6096-4e17-87a5-ac5f48c0d345.png)

- 入力画面とプレビュー

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126852/5a9bfe1f-c353-4d7c-a6a0-4396ef5196fa.png)

- 投稿依頼の直後

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126852/ef52415c-98c3-4208-a88f-a042d3d4b520.png)

- AI エージェントの応答

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126852/a83682af-36bc-4266-a822-c4d0960cf7f5.png)

- 公開されたページを表示

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126852/9b68ff48-c93d-47b3-99c3-6a4fc729cfaa.png)

- OpenAI Platform のトレース情報

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126852/7b9f0a1f-19a7-4dd6-b30c-29ba7d6755ac.png)


### リジェクトされる例


- 薄い記事を作成

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126852/64bb1da3-ec42-4473-9c08-76595d78c4f7.png)


- AI エージェントの現実的な応答


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126852/72a54f53-2b4b-496d-a089-794cd921ef05.png)

- OpenAI Platform のトレース情報

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126852/5871cc26-a5a0-48ee-9346-9d95c4e26dfc.png)