Gemini APIが応用情報の勉強を手伝ってくれるアプリを作ろう（1: クイズ機能の実装編）

# はじめに
### 自己紹介と記事の目的
つい先日大学院を修了し、既卒としてエンジニア就活してます。
現在Webアプリケーションについて勉強中です。

時間があるので応用情報技術者を取得しようと思ったのですが、せっかくなら生成AIを使ってそれをサポートできないかと考えて、生成AIを組み込んだWebアプリケーションを作ることにしました。

最終的には生成AIを生かした複数の機能を実装し、自分以外のユーザーにも使ってみてほしいので、その足がかりを作るためにも制作過程を記事にしていこうと思っています。
同じようにWebアプリ開発を勉強している方々の一助になれば幸いです。



### 今回実装するクイズ機能の完成イメージ
![クイズ画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4069717/ee93f2e6-9df4-4a71-b84d-2904f58c133f.png)
こんな感じで、応用情報に出現しそうなIT用語に関するクイズをAIに出題してもらう機能を実装していきます。

### こんな人向け
+ Gemini APIを使ってWebアプリケーションを作ってみたい

### 今回説明しないこと
+ React＋Viteでの環境構築やプロジェクトの作成方法
+ CSSなどのレイアウトに関係する部分
+ TypescriptやReactの基本知識


# 使用技術・開発環境
+ React 19.0.0（Vite v6.2.6） 
+ TypeScript  
+ Google Gemini API
+ MaterialUI 
+ Tailwind CSS
+ Vscode 
+ Github Copilot

特にレイアウトの部分はCopilotにバリバリ書いてもらっています...


# 事前準備

### Tainwind CSSのインストール

https://tailwindcss.com/docs/installation/using-vite

```
npm install tailwindcss @tailwindcss/vite
```
index.cssに以下を記述しておいてください。
```css:index.css
@import "tailwindcss";
```
vite.confingにも追加が必要です。
```vite.config.ts
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react-swc'

export default defineConfig({
  plugins: [react(), tailwindcss()],
  
})
```

### MaterialUIのインストール

https://mui.com/material-ui/getting-started/installation/

```
npm install @mui/material @emotion/react @emotion/styled
```


# Gemini APIの使い方

参考（公式サイト）: 

https://ai.google.dev/gemini-api/docs/libraries?hl=ja#javascript

### 1. npmインストール
```
npm install @google/genai
```

:::note warn
以前は @google/generative-ai というパッケージが主に使われていましたが、現在では @google/genai がリリースされこちらへの移行を推奨しているそうです。（generative-aiは2025年8月にサポート終了）
:::


### 2. APIキーの取得
Gemini APIに限らず、各種APIを利用するためにはAPIキーと呼ばれるAPI提供者から発行される一意の認証情報が必要です。

今回はGoggle AI Stdioの無料枠を使用します。APIキーはサイトの指示に従っていけば簡単に発行できます。

https://ai.google.dev/gemini-api/docs/api-key?hl=ja




### 3. APIキーの隠蔽
APIキーをコード上にそのまま記述していると、Githubなどでプロジェクトを公開したとき他のユーザーにキーが漏洩してしまいます。

そのため、APIキーは環境変数に隠し、使用する際に変数を読み込む形にします。
詳しくは他の方が書いた記事などを参照してください。


プロジェクト直下に.envファイルを作成
```.env
VITE_GEMINI_KEY = ここにAPIキー
```
.gitignoreに以下を追加
```.gitignore
.env
```



### 4. gemini.tsの実装
APIを使用したロジック部分は、gemini.tsというファイルを作成し記述していきます。
まずは環境変数からAPIキーを受け取りインスタンスを生成します。

```typescript:gemini.ts
import { GoogleGenAI} from "@google/genai";

// 環境変数の読み込み
const GEMINI_API_KEY: string = import.meta.env.VITE_GEMINI_KEY;

// APIキーを使用してGoogleGenerativeAIのインスタンスを作成
const genAI = new GoogleGenAI({apiKey: GEMINI_API_KEY});
```

次に、ロジック部分を実装します。
今回想定される使い方は以下の2つです。
+ 特定の用語に対しクイズを生成してもらう
+ クイズに対し、ユーザーの回答が合っているか間違っているかを判定してもらう

2つ目に関しては単純な文字列比較などを利用してもよいのですが、複数の呼び方が許される場合などを考えると面倒なので、どうせならAIにやってもらいましょう。

それぞれ必要となるプロンプトは異なりますが、用途に合ったプロンプトを受け取りAIモデルに回答してもらう処理は共通しているので、プロンプトを受け取って、AIの回答テキストを返す関数を実装します。


```typescript:gemini.ts
const callGeminiAPI =  async (prompt: string): Promise<string> => {
  // プロンプトに基づいてGemini APIを呼び出し 出力を取得
  const response = await genAI.models.generateContent({
    model: "gemini-2.0-flash",
    contents: prompt,
    config: {
      maxOutputTokens: 500,
      temperature: 0.1,
    },
  });

  // テキストを抽出する前に undefined の可能性をチェック
  if (response.text === undefined) {
    console.error("Geminiモデルからの応答にテキストが含まれていませんでした。")
    throw new Error("Failed to generate quiz text: No text in response.");
  }
  // 生成されたテキストを取得
  return response.text;
}
```
これでgemini.tsの実装は完了です。本体部分でプロンプトを用意し、関数を呼び出すようにします。


# クイズの解答となる用語の準備
クイズの解答候補となるIT用語を用意します。
試しに、応用情報に出現するIT用語をGeminiに10個生成してもらいました。

![Gemini利用例.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4069717/a4a97b0a-4a56-4d92-a171-75d6dd63d387.png)


実際のアプリ開発ではデータベースに保存したいところですが、
今回は簡単のためプロジェクト内に直接定義しておきます。

```typescript:App.tsx
const itTermsForExam: string[] = [
  "ITIL", // サービスマネジメント
  "PaaS", // クラウドサービスモデル
  "ブロックチェーン", // 新技術・分散システム
  "SCM", // 経営戦略・システム戦略
  "SLA", // サービスレベル管理
  "アジャイル開発", // ソフトウェア開発手法
  "DDoS攻撃", // セキュリティ・脅威
  "IoT", // ハードウェア・ネットワーク
  "正規化", // データベース設計（より専門的）
  "BCP" // リスクマネジメント・事業戦略
];
```

# 各種プロンプトの用意
特定の用語を解答とするようなクイズの問題文を生成してもらうプロンプト
```typescript:App.tsx
const quizGeneratePrompt = `${answerWord}を解答とするようなクイズの問題文を一問生成してください。\n
        問題文以外は出力しないでください。\n
        ${answerWord}を使用した問題ではなく、必ず${answerWord}という用語名自体が解答となるような問題にしてください。\n`;
```

クイズの正解判定をしてもらうプロンプト
```typescript:App.tsx
const quizCheckPrompt = `次のクイズに対するユーザーの回答が正しいかどうかを判定してください。
        問題文: ${questionText}\n
        正解: ${answerWord}\n
        ユーザーの回答: ${userAnswer}\n
        正しい場合は「正解」、間違っている場合は「不正解」とだけ出力してください。`;
```

# クイズ画面の実装
Reactらしく、以下の機能ごとにcomponentを分割して簡単に実装していきます。
+ 問題文
+ 回答フォーム
+ 次の問題へボタン


### 問題文の表示
```typescript:QuestionDisplay
import ReactMarkdown from "react-markdown";

type Props = {
  questionText: string;
  isLoading: boolean;
};

const QuestionDisplay = ({ questionText, isLoading }: Props) => (
  // 問題文のコンテナ
  // 生成中状態と問題文表示状態で異なる内容を表示
  <div className="flex justify-center items-center w-3/4 mt-20 bg-[#f5faff] rounded-xl p-4 min-h-[120px] mb-4 border-2 border-[#1976d2] shadow-lg">
    {isLoading ? (
      <p className="text-lg">生成中...</p>
    ) : (
      <div className="text-lg">
        <ReactMarkdown>{questionText}</ReactMarkdown>
      </div>
    )}
  </div>
);

export default QuestionDisplay;
```



### 回答フォームと送信ボタン
```typescript:AnswerForm.tsx
import TextField from "@mui/material/TextField";
import IconButton from "@mui/material/IconButton";
import SendIcon from "@mui/icons-material/Send";

type Props = {
  userAnswer: string;
  setUserAnswer: (value: string) => void;
  checkAnswer: () => void;
  isLoading: boolean;
  inputRef: React.RefObject<HTMLInputElement | null>;
};

const AnswerForm = ({ userAnswer, setUserAnswer, checkAnswer, isLoading, inputRef }: Props) => (
  <div className="mt-8 flex items-center space-x-2">
    
    {/* 回答入力フィールド */}
    <TextField
      label="あなたの回答"
      variant="outlined"
      value={userAnswer}
      onChange={(e) => setUserAnswer(e.target.value)}
      disabled={isLoading}
      size="medium"
      className="min-w-[240px]"
      inputRef={inputRef}
      onKeyDown={(e) => {
        if (
          e.key === "Enter" &&
          !isLoading &&
          userAnswer.trim() !== ""
        ) {
          checkAnswer();
        }
      }}
    />

    {/* 送信ボタン */}
    <IconButton
      color="primary"
      onClick={checkAnswer}
      disabled={isLoading || !userAnswer}
      className="h-10 w-10 ml-1 mt-[7px] border border-[#1976d2] bg-[#f5faff] flex items-center justify-center"
    >
      <SendIcon className="text-[28px] align-middle" />
    </IconButton>
  </div>
);

export default AnswerForm;
```

### 次の問題へボタン
```typescript:ControlButtons.tsx
import Button from "@mui/material/Button";

type Props = {
  nextAction: () => void;
};

const ControlButtons = ({ nextAction }: Props) => (
  <div className="w-full flex justify-center gap-4 mt-6">
    {/* 次の問題へボタン */}
    <Button
      variant="contained"
      color="primary"
      onClick={nextAction}
      className="min-w-[200px] font-bold text-sm"
    >
      次の問題へ
    </Button>
  </div>
);

export default ControlButtons;
```

### 全体のソースコード
```typescript:App.tsx
import { useState, useEffect, useRef } from 'react';
import { callGeminiAPI } from './utils/gemini';

import './App.css'

// コンポーネントのインポート
import AnswerForm from './components/AnswerForm';
import QuestionDisplay from './components/QuestionDisplay';
import ControlButtons from './components/ControlButtons';


function App() {
    const [questionText, setQuestionText] = useState(''); // 生成されたクイズ問題文
    const [answerWord, setAnswerWord] = useState(''); // クイズの解答となる用語 
    const [userAnswer, setUserAnswer] = useState(''); // ユーザーの回答
    const [isLoading, setIsLoading] = useState(false); // クイズ生成中のローディング状態の管理
    const inputRef = useRef<HTMLInputElement>(null); // 入力フィールドの参照

    // クイズの解答となる用語の候補
    const itTermsForExam: string[] = [
        "ITIL", // サービスマネジメント
        "PaaS", // クラウドサービスモデル
        "ブロックチェーン", // 新技術・分散システム
        "SCM", // 経営戦略・システム戦略
        "SLA", // サービスレベル管理
        "アジャイル開発", // ソフトウェア開発手法
        "DDoS攻撃", // セキュリティ・脅威
        "IoT", // ハードウェア・ネットワーク
        "正規化", // データベース設計（より専門的）
        "BCP" // リスクマネジメント・事業戦略
      ];


    //配列からランダムに単語を選んで返す関数
    const pickRandomWord = (wordList: string[]): string => {
        if (wordList.length === 0) {
            console.error("Answer words list is empty");
            return "";
        }
        const randomIndex = Math.floor(Math.random() * wordList.length);
        return wordList[randomIndex];
    };

    // Gemini APIを呼び出してクイズを一問生成しステートに保存する関数
    const generateQuiz = async () => {
        const answerWord: string = pickRandomWord(itTermsForExam)// 候補からランダムに選ばれた用語を取得
        setAnswerWord(answerWord); // 正解判定用のStateにセット
        setIsLoading(true); // ローディング状態を開始
        setUserAnswer(""); // ユーザーの回答をリセット
        // プロンプトを生成
        const quizGeneratePrompt = `${answerWord}を解答とするようなクイズの問題文を一問生成してください。\n
            問題文以外は出力しないでください。\n
            ${answerWord}を使用した問題ではなく、必ず${answerWord}という用語名自体が解答となるような問題にしてください。\n`;
        try {
            const response = await callGeminiAPI(quizGeneratePrompt);
            setQuestionText(response);
        } catch (error) {
            console.error("Error calling Gemini API:", error);
            setQuestionText("エラーが発生しました");
        } finally {
            setIsLoading(false); // ローディング状態を終了
        }
    };

    // クイズの回答を送信し正解判定を行う関数
    const checkAnswer = async () => {
        if (userAnswer === "") {
            alert("回答を入力してください");
            return;
        }
        //setIsLoading(true); // ローディング状態を開始
        const quizCheckPrompt = `次のクイズに対するユーザーの回答が正しいかどうかを判定してください。
            問題文: ${questionText}\n
            正解: ${answerWord}\n
            ユーザーの回答: ${userAnswer}\n
            正しい場合は「正解」、間違っている場合は「不正解」とだけ出力してください。`;
        try {
            const result = await callGeminiAPI(quizCheckPrompt);
            alert(`${result}\n正解: ${answerWord}`);
            generateQuiz();
            setUserAnswer("");
        } catch (error) {
            console.error("Error checking answer:", error);
            alert("エラーが発生しました");
            generateQuiz();
            setUserAnswer("");
        } finally {
        //setIsLoading(false); // ローディング状態を終了
        }
    }

    //クイズスタート時に一問目を生成するためのuseEffect
    useEffect(() => {
        if (itTermsForExam.length > 0) {
            generateQuiz();
        }
    }, []);

    // 問題が変わるたびに回答フォームにフォーカスするためのuseEffect
    useEffect(() => {
        if (inputRef.current) {
            inputRef.current.focus();
        }
    }, [questionText, isLoading]); // 
  

  return (
    <div className="w-full flex flex-col justify-center items-center">
        {/* 問題文の表示  */}
        <QuestionDisplay questionText={questionText} isLoading={isLoading} />

        {/* 回答フォームと送信ボタン */}
        <AnswerForm
            userAnswer={userAnswer}  
            setUserAnswer={setUserAnswer}
            checkAnswer={checkAnswer}
            isLoading={isLoading}
            inputRef={inputRef} // 入力フィールドの参照を渡す
        />
        
        {/* 次の問題へボタン */}
        <ControlButtons
            nextAction={generateQuiz}
        />
    </div>
  )
}

export default App
```


# 完成画面
![使用1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4069717/52a1e78f-034b-4e9f-a35e-9890d22c8b12.png)
![使用2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4069717/7a801fc9-d779-4d1b-8869-6d31f165b59d.png)


現時点ではシンプルな作りですし、生成されたクイズが実際に有用かどうかはわかりませんが、ひとまずは形になったと思います。


# おわりに
今回は、応用情報技術者試験に向けて、Gemini APIを利用した簡単なクイズアプリケーションについて実装しました。

次回は、Geminiがマルチモーダルな入力を可能な点を生かして、音声を受け取って解釈してもらう機能について実装・解説しようと思います。