OpenAI APIであらゆるジャンルのクイズを無限に作る（Function Calling、JSON Mode）

OpenAIの[Chat Completion API](https://platform.openai.com/docs/guides/text-generation/chat-completions-api)を使って、あらゆるジャンルのクイズを無限に作るクイズAPI[^1]を作ってみました。利用するためには、OpenAIのAPIトークンが必要です。

PythonのStreamlitで作ったデモアプリの中で使っています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/4ad3522a-9749-ab4d-4341-077859211739.png" alt="無限クイズの画面" width="400">

https://github.com/takatama/infinity-quiz

[^1]: 英語だと[Open Trivia DB API](https://opentdb.com/api_config.php)や[The Trivia API](https://the-trivia-api.com/)といったクイズAPIがあるんですが、日本語のものが見つかりませんでした...。

# クイズのデータ形式

ジャンルを指定すると、Chat Completion APIを使って、次のようなJSON形式の4択クイズデータを生成します。次の例はジャンルに`Python`を指定した結果です。

```json
{
  "questions": [
    {
      "question": "Pythonの特徴でないものはどれ？",
      "options": ["動的な型付けを採用している", "クラスベースのオブジェクト指向プログラミング言語である", "セミコロンが必須である", "インデントによるブロック表現が特徴的"],
      "answerIndex": 2
    },
    {
      "question": "Pythonで使用されるイテレーターを作成するための関数はどれ？",
      "options": ["enumerate()", "zip()", "range()", "iter()"],
      "answerIndex": 3
    },
    {
      "question": "Pythonの有名なウェブフレームワークはどれ？",
      "options": ["Django", "Ruby on Rails", "Express", "Flask"],
      "answerIndex": 0
    }
  ]
}
```

選択肢のうち、どれが正解かを`answerIndex`で指定させるのがポイントです。こうすることで、選択肢の中に必ず正解があることを保証できます[^2]。

[^2]: 最初は、選択肢と正解をそれぞれ文字列でGPTに作らせていたのですが、選択肢の中に正解の文字列がない、という状況になったことがありました。

Chat Completion APIにJSON形式のデータを作らせるには、JSON ModeかFunction Callingを使います。それぞれ説明していきます。

なお、プロンプトインジェクションへの対策は施していません。あくまで参考としてご覧ください。

# JSON Modeを使うクイズAPI

[JSON Mode](https://platform.openai.com/docs/guides/text-generation/json-mode)は、`gpt-3.5-turbo-1106`や`gpt-4-1106-preview`といった新しいモデルで使える機能です。この後説明するFunction Callingよりも簡単に、JSON形式のデータを生成できます。

```python
def create_quiz_by_json_mode(genre, num_questions, num_options):
    client = OpenAI(
        api_key=os.getenv("OPENAI_API_KEY"),
    )
    response = client.chat.completions.create(
        model=os.getenv("OPENAI_MODEL"),
        messages=[
            {
                "role": "system",
                "content": 'あなたは優秀なアシスタントです。あなたはあらゆるジャンルのクイズを作ることが出来ます。日本語で回答してください。lang:ja。{"questions: [{"question": "問題", "options":["回答1", "回答2", "回答3", "回答4"], "answerIndex": 0},...]}のJSON形式で返却してください。',
            },
            {
                "role": "user",
                "content": f"ジャンル「{genre}」に関するクイズを、{num_questions}問作ってください。{num_options}択クイズでお願いします.",
            },
        ],
        response_format={"type": "json_object"},
    )
    json_obj = json.loads(response.choices[0].message.content)
    print(json_obj)
    return json_obj["questions"]
```

JSON Modeを使うポイントは4つです。

## 1. 新しいモデルを使う

`gpt-3.5-turbo-1106`や`gpt-4-1106-preview`を使います。

## 2. messages（プロンプト）に返却して欲しいデータ形式を指定する

今回は、Systemプロンプトに次のようなデータ形式を指定しています。

> **{"questions: [{"question": "問題", "options":["回答1", "回答2", "回答3", "回答4"], "answerIndex": 0},...]}** のJSON形式で返却してください。

ただし、データ形式の指定が甘いと、返却されるデータの構造がぶれることがあります。

例えば、プロンプトで期待するデータ形式を`{"question": "問題", "options":["回答1", "回答2", "回答3", "回答4"], "answerIndex": 0}`とだけ指定していると、

```json
{
  "questions":[
    {
      "question": "問題",
      "options":["回答1", "回答2", "回答3", "回答4"],
      "answerIndex": 0
    },
    ...
```
と返却される場合だけでなく、

```json
{
  "quiz1": {
    "question": "問題",
    "options":["回答1", "回答2", "回答3", "回答4"],
    "answerIndex": 0
  },
  "quiz2": {
    ...
```

と返却される場合もありました。

## 3. messages（プロンプト）に`json`という文字列を入れる

もし、`json`という文字列を入れ忘れると、400エラーレスポンスが返却されます。

> openai.BadRequestError: Error code: 400 - {'error': {'message': "**'messages' must contain the word 'json'** in some form, to use 'response_format' of type 'json_object'.", 'type': 'invalid_request_error', 'param': 'messages', 'code': None}}

## 4. `response_format`を指定する

呼び出すときに`response_format={"type": "json_object"}`を指定します。

# Function Callingを使うクイズAPI

[Function Calling](https://platform.openai.com/docs/guides/function-calling)を使って、JSON形式のデータを返却させることもできます。古いモデルで動かせることができるのですが、指定が少々面倒です。

```python
def create_quiz_by_function_calling(genre, num_questions, num_options):
    client = OpenAI(
        api_key=os.getenv("OPENAI_API_KEY"),
    )
    response = client.chat.completions.create(
        model=os.getenv("OPENAI_MODEL"),
        messages=[
            {
                "role": "system",
                "content": "あなたは優秀なアシスタントです。あなたはあらゆるジャンルのクイズを作ることが出来ます。日本語で回答してください。lang:ja。",
            },
            {
                "role": "user",
                "content": f"ジャンル「{genre}」に関するクイズを、{num_questions}問作ってください。{num_options}択クイズでお願いします.",
            },
        ],
        tools=[
            {
                "type": "function",
                "function": {
                    "name": "show_questions",
                    "description": "show given questions",
                    "parameters": {
                        "$schema": "http://json-schema.org/draft-07/schema#",
                        "type": "object",
                        "properties": {
                            "questions": {
                                "type": "array",
                                "items": {
                                    "type": "object",
                                    "properties": {
                                        "question": {"type": "string"},
                                        "options": {
                                            "type": "array",
                                            "items": {"type": "string"},
                                        },
                                        "answerIndex": {
                                            "type": "integer",
                                            "minimum": 0,
                                        },
                                    },
                                    "required": ["question", "options", "answerIndex"],
                                    "additionalProperties": False,
                                },
                            }
                        },
                        "required": ["questions"],
                        "additionalProperties": False,
                    },
                },
            }
        ],
        tool_choice={"type": "function", "function": {"name": "show_questions"}},
    )
    message = response.choices[0].message
    args = json.loads(message.tool_calls[0].function.arguments)
    print(args)
    return args["questions"]
```

Function Callingにクイズデータを作らせるために、show_questions関数を持つクイズ表示アプリが存在すると仮定します。show_questions関数にクイズデータ渡すと、それを表示してくれます。利用イメージは次の通りです。

```mermaid
sequenceDiagram
    App ->> Chat Completion API:　Pythonの3択クイズを3問、表示したい
    Chat Completion API -->> App: show_questionsに渡すデータ（questions）
    App ->> クイズ表示アプリ : show_questions(questions)
    
```

Appは存在しないshow_questions関数を呼び出すことはしませんが、クイズデータ（questions）は取得できています。

Function Callingを使うポイントは2つです。

## 1. `tool_choice`で呼び出す関数名を指定

今回はshow_questions関数を呼び出すと仮定しているので、次のようにしています。

`tool_choice={"type": "function", "function": {"name": "create_questions"}}`

## 2. `tools`に呼び出す関数の引数をJSON Schemaで指定

今回はshow_questionsの関数の引数はquestionsです。このquestionsが先に提示したクイズデータになります。

引数questionsのデータ形式は[JSON Schema](https://json-schema.org/understanding-json-schema)で定義します。ChatGPTにquestionsの例を提示すると、それをJSON Schemaに変換してくれます。

<details><summary>ChatGPTとのやりとり</summary>

### You

次のJSONのJSON Schemaを教えて

```json
{
  "questions": [
    {
      "question": "Pythonの特徴でないものはどれ？",
      "options": ["動的な型付けを採用している", "クラスベースのオブジェクト指向プログラミング言語である", "セミコロンが必須である", "インデントによるブロック表現が特徴的"],
      "answerIndex": 2
    },
    {
      "question": "Pythonで使用されるイテレーターを作成するための関数はどれ？",
      "options": ["enumerate()", "zip()", "range()", "iter()"],
      "answerIndex": 3
    },
    {
      "question": "Pythonの有名なウェブフレームワークはどれ？",
      "options": ["Django", "Ruby on Rails", "Express", "Flask"],
      "answerIndex": 0
    }
  ]
}
```

### ChatGPT

提供されたJSONデータのためのJSON Schemaを作成する際、以下のような構造を考慮します：

- questionsはオブジェクトの配列です。
- 各オブジェクトにはquestion（文字列型）、options（文字列の配列）、そしてanswerIndex（整数型）が含まれます。
 
この情報を基に、対応するJSON Schemaは以下のようになります：

```json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "questions": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "question": {
            "type": "string"
          },
          "options": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "answerIndex": {
            "type": "integer",
            "minimum": 0
          }
        },
        "required": ["question", "options", "answerIndex"],
        "additionalProperties": false
      }
    }
  },
  "required": ["questions"],
  "additionalProperties": false
}
```

このスキーマでは以下の点を確認しています：

- 最上位レベルはオブジェクトで、questionsというプロパティを持ちます。
- questionsはオブジェクトの配列で、各オブジェクトはquestion（文字列）、options（文字列の配列）、answerIndex（0以上の整数）を持つ必要があります。
- requiredフィールドを使用して、これらのプロパティが各オブジェクトに必須であることを指定しています。
- additionalProperties: falseを設定して、スキーマに定義されていない追加のプロパティがオブジェクトに含まれないようにしています。

このスキーマは、提供されたJSONデータの構造を適切に反映しています。

</details>

`tools`に呼び出す関数を指定します。

- 関数名`"name": "show_questions"`
- 関数の説明`"description": "show given questions"`
- 関数の引数`properties`（JSON Schemaで定義）

```python
        tools=[
            {
                "type": "function",
                "function": {
                    "name": "show_questions",
                    "description": "show given questions",
                    "properties": { # ChatGPTに提示されたJSON Schema
                    ...
```

# まとめ

OpenAIのChat Completion APIを使って、ジャンルに応じた無限のクイズを生成する方法を紹介しました。

使えるモデルに応じて、JSON ModeとFunction Callingを使い分けてもらえればと思います。ただし、プロンプトインジェクションへの対策が施されていない点と、生成されるクイズに嘘（ハルシネーション）がある点には注意が必要です。4択クイズであっても「該当なし」の選択肢を作ってあげるのが良さそうです。

クイズはエンターテイメントとして楽しいだけでなく、教育に応用すること可能です。例えば、自分が何かを学んだ後、力試しとしてクイズを解いてみれば、理解度をすぐに確認できます。他にも色々な応用がありそうです。

このAPIを使ってみて、ご意見や改善案があればぜひ教えてください！