Vercel AI SDKで世界遺産検定のWEBアプリを作った

# はじめに
最近奥さんが世界遺産検定を受けると言っていました。しかし中々いいアプリが無いとのこと。

そんなおり最近のopen aiの動向を調べていたところ、gpt-4-1106-previewにてJSON modeと呼ばれるJSON形式での結果の返却を保証する機能が追加されていることを知りました。また、vercelが開発しているVercel ai sdkも試してみたかったのでちょうどいい機会だと思い世界遺産検定問題を作成するWEBアプリを作成してみました。

## Vercel AI SDKについて
https://sdk.vercel.ai/docs

## gpt-4-1106-previewのJSON modeについて
https://platform.openai.com/docs/guides/text-generation/json-mode

以下のように```response_format: { type: "json_object" } ```と指定するとJSONモードが有効になります。

```javascript
import OpenAI from "openai";

const openai = new OpenAI();

async function main() {
  const completion = await openai.chat.completions.create({
    messages: [
      {
        role: "system",
        content: "You are a helpful assistant designed to output JSON.",
      },
      { role: "user", content: "Who won the world series in 2020?" },
    ],
    model: "gpt-3.5-turbo-1106",
    response_format: { type: "json_object" },
  });
  console.log(completion.choices[0].message.content);
}

main();
```

今回は、この機能に着目して世界遺産検定の問題を出題するWEBアプリを作ってみました。

# 完成イメージ
出題するボタンを押すとgpt-4エンジン問い合わせをして、世界遺産検定向けの問題と答えを返却してくれます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/896062/30a05c54-a919-327d-1f2e-8f1612766880.png" width=30% />

ラジオボタンで選択肢を選び、回答するを押下すると答えを表示します。答えには、google custom search apiで検索してきた画像とgoogle maps apiで位置情報を表示してくれます。
<p>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/896062/255b60d5-0132-d749-86bc-551f33680e49.png" width=30% />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/896062/bb1b7898-8550-783d-38bb-9d8ec13045db.png" width=30% />
</p>
世界遺産検定では登録基準の番号を答えさせられますが、番号の意味はツールチップで表示することもできます。これで試験に受かるもの間違いなしですね！笑
<p>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/896062/5ab8e52d-f185-6667-cf0f-45f0cf9cb99a.png" width=30% />
</p>

# 環境情報
- ホスティングサービス
  - Vercel / Nextjs 14.0.2
- 開発言語 / ライブラリ
  - react 18
  - typescript 5
  - tailwind css 3.3.0
  - daisyui 3.9.4
  - vercel ai sdk
- その他
  - open api api
  - google maps API
  - google custom search API

# リポジトリ
https://github.com/Haradasn/world_heritage_app

# コードの中身
## コンテンツを作成するAPIの部分
一番の肝であるAPIでは、以下のプロンプトを与えてjsonデータを取得しています。また、tempratureやtop_pの各パラメータも調節することで、欲しいレベルの問題が抽出されるようにしています。

```typescript:src/app/api/completion/route.ts
const response = await openai.chat.completions.create({
    model: "gpt-4-1106-preview",
    stream: true,
    // a precise prompt is important for the AI to reply with the correct tokens
    messages: [
      {
        role: "user",
        content: `# 実施項目
        カテゴリ${prompt}の世界遺産検定1級の問題を過去問から日本語で作成せよ。以下のJSONフォーマットを返却せよ。        
        
        # JSONフォーマット
        {
          "question": "世界遺産検定1級の問題(string)",
          "choices": [
            "A.回答の選択肢(string)",
            "B.回答の選択肢(string)",
            "C.回答の選択肢(string)",
            "D.回答の選択肢(string)"
          ],
          "answer": {
            "choice": "解答の選択肢のアルファベット(string).選択肢",
            "heritage-name": "世界遺産の正式名称(string)",
            "country": "世界遺産のある国名(string)",
            "year_of_registration": "世界遺産登録年(string)",
            "image": "世界遺産画像のURL"
            "criterions": "世界遺産登録基準(i)～(x)(array)",
            "longitude": "googlemapの経度(integer)",
            "latitude": "googlemapの緯度(integer)",
            "description": "世界遺産概要(string)"
          }
        }`,
      },
    ],
    max_tokens: 1000,
    temperature: 2, // 単語の確率分布を変える
    top_p: 0.8, // 選択肢を制限
    frequency_penalty: 0.5,
    presence_penalty: 0.5,
    response_format: { type: "json_object" },
  });
```
## 問題を表示するページ部分
ページ部分はClient componentで作成しています。daisyuiとtailwind cssを駆使して、UIへはあまり労力を割かないように意識して作成しました。

```typescript:src/app/page.tsx
"use client";
import Button from "@/components/button";
import Card from "@/components/card";
import Select from "@/components/select";
import { useState } from "react";
import { useCompletion } from "ai/react";
import { Question } from "./question";
import { Answer } from "@/components/answer";

type Answer = {
  "heritage-name": string;
  choice: string;
  country: string;
  year_of_registration: string;
  image: string;
  criterions: string[];
  description: string;
  longitude: string;
  latitude: string;
};
export type QuestionAnswer = {
  question: string;
  choices: string[];
  answer: Answer;
};

export default function Home() {
  const [isAnswer, setIsAnswer] = useState(false);
  const [imageLink, setImageLink] = useState({ data: "" });
  const [selectValue, setSelectValue] = useState("");
  const [selectedRadio, setSelectedRadio] = useState("");
  const [questionAnswer, setQuestionAnswer] = useState<
    QuestionAnswer | undefined
  >();
  const handleValueChange: React.ChangeEventHandler<HTMLSelectElement> = (
    event
  ) => {
    if (event) {
      setSelectValue(event.target.value);
    }
  };
  const generateQuestion = async () => {
    setIsAnswer(false);
    await complete(selectValue);
  };
  const { complete, isLoading } = useCompletion({
    api: "/api/completion",
    onFinish: (prop, completion) => {
      const parsedCompletion = JSON.parse(completion);
      setQuestionAnswer(parsedCompletion);
      getImage(
        `${parsedCompletion?.answer["heritage-name"]}　${parsedCompletion?.answer?.country}`
      );
    },
  });
  const getImage = async (query: string | undefined) => {
    if (query) {
      const link = await fetch(
        `${
          process.env.NEXT_PUBLIC_SITE_URL
        }/api/image?query=${encodeURIComponent(query)}`,
        {
          method: "GET",
        }
      ).then((res) => res.json());
      setImageLink(link);
    }
  };
  return (
    <>
      <main className="flex min-h-screen flex-col items-center p-3 overflow-hidden">
        <div id="question" className="w-full md:w-[889px]">
          <div className="my-4">
            <Card>
              <h2 className="card-title">世界遺産 AI ウェーブ</h2>
              <div className="flex-start">
                <div className="flex whitespace-nowrap">
                  <Select onChange={handleValueChange} disabled={isLoading} />
                  <Button
                    to="#"
                    onClick={generateQuestion}
                    disabled={isLoading}
                  >
                    出題する
                  </Button>
                </div>
              </div>
              {isLoading ? (
                <>
                  AIが問題を作成中
                  <span className="loading loading-dots loading-xs"></span>
                </>
              ) : (
                questionAnswer && (
                  <Question
                    questionAnswer={questionAnswer}
                    onClick={() => {
                      setIsAnswer(true);
                    }}
                    isAnswer={isAnswer}
                    selectedRadio={selectedRadio}
                    radioButtons={questionAnswer.choices}
                    setSelectedRadio={setSelectedRadio}
                  />
                )
              )}
            </Card>
          </div>
          <div></div>
          {questionAnswer && (
            <Answer
              questionAnswer={questionAnswer}
              isAnswer={isAnswer}
              imageLink={imageLink?.data}
              selectedRadio={selectedRadio}
            />
          )}
        </div>
      </main>
    </>
  );
}
```
## その他
gpt-4に世界遺産の位置情報を返してもらうことで、google mapで位置情報も表示させることが出来ます。世界遺産の位置は中々覚えづらいのでgpt × google mapは結構いい組み合わせだと思います。
また、正解の選択肢をgoogle custom search apiで画像検索することで、どんな世界遺産なのかも視覚的イメージできるようになっています。

## 作ってみた感想
必ずjson形式で返してくれるようになったので、エラーが発生する頻度も低く、かなり使えるアプリにが出来たなと思います。このアプリは実働大体4日くらいで作りました。最近のNextjs/vercelでの開発は爆速になりつつあるなと感じます。

## 終わりに
今回のアプリはgptの応答が遅いことが課題なのですが、ユーザーが使用する前にあらかじめAPI実行を行いデータベースにいくつか問題コンテンツを登録しておけば速度を気にしなくて済みそうだなと思っています。Vercelのpostgresはまだ使ったことが無いので、これを気に使ってみて、また記事を更新出来たらなと思います。
https://vercel.com/docs/storage/vercel-postgres

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