LoginSignup
5
5

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

Posted at

はじめに

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

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

Vercel AI SDKについて

gpt-4-1106-previewのJSON modeについて

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

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エンジン問い合わせをして、世界遺産検定向けの問題と答えを返却してくれます。

ラジオボタンで選択肢を選び、回答するを押下すると答えを表示します。答えには、google custom search apiで検索してきた画像とgoogle maps apiで位置情報を表示してくれます。

世界遺産検定では登録基準の番号を答えさせられますが、番号の意味はツールチップで表示することもできます。これで試験に受かるもの間違いなしですね!笑

環境情報

  • ホスティングサービス
    • 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

リポジトリ

コードの中身

コンテンツを作成するAPIの部分

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

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へはあまり労力を割かないように意識して作成しました。

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

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

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5