ChatGPTとGoogleスプレッドシートで実現する自動採点学習システム（プログラミング不要）

# ChatGPTとGoogleスプレッドシートで実現する自動採点学習システム
## 0. はじめに
　人間の知的活動は、何かと何かを対応付けることが重要な地位を占めています（←言い過ぎかも）。どのような学問分野でも、基本的な単語で表される概念（例えば「加速度」と「力」）の理解が大事です。「理解」は、その概念を自分の中の既成の概念と対応させることと言えましょう。そして、学びは、基本的な概念同士（例えば『加速度」と「力」）の対応関係（例えば『運動方程式」としての対応関係）を理解することへ進んでいきます。学問の入り口では、基本的な単語で表される概念を自分のものにしてくプロセスがとても重要です。
 
　しかし、教員が一人ひとりの生徒に対応するのは難しいですね。そこで、OpenAIのChatGPTとGoogleスプレッドシートを活用し、自動でフィードバックを提供する学習システムを紹介します。
## 1. システムの概要
### 1-1. 前提条件
　次のような要件を満たしていれば誰でも実現できます！
 * 出題者は、課金を支払い、OpenAI API の APIキーを取得している。
 * 出題者は、Google スプレッドシートが利用できる。
 * 受講者（ユーザー）は、 Google アカウントを持っている。（本学のメールはGmail。）

### 1-2. 動作の様子
　ウェブで表示された問題に対して答えを書き込み、「送信」をクリックします。するとChatGPTからの得点とフィードバックが表示されます。これを繰り返し実行することで学びが定着します。
 ![画像1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253484/647ad377-4d23-8ac1-132c-3944efda5285.png)

実行結果は別のスプレッドシートに自動的に保存されます。
![画像2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253484/a247ca27-73ce-472c-9a29-9a0341e8e246.png)
<iframe width="560" height="315" src="https://www.youtube.com/embed/0Dhpm4X69K4?si=8KxNPEoUCP_eJruT" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

### 1-3. 全体の構成
1. 問題用シート： 問題、解答例などを書き込んだGoogleスプレッドシート。正解などが書いてあるので、ユーザーからは見えないようにします。問題用シートには、「コンテナバインド」のGASプログラムとHTMLファイルが付随しています。プログラムがくっついている、ということです。
    * GASプログラム(コード.gs)
    * HTMLファイル(index.html)
1. 記録用シート： ユーザーのメールアドレス、ユーザーの解答、得点が記録されます。これもプライバシーのためにユーザーには見せなません。

## 2. 実現方法
　ここでは、プログラミングせずに Google ドライブのファイルをコピーして設定してみます。
### 2-1. 記録用シートの準備
1. [記録用シート](https://docs.google.com/spreadsheets/d/1kfHD3H4b4sBirranQk00LAvDzjXYGvi9I_2v9GfAVCQ/edit?usp=drive_link)を開いて、ファイル＞コピーして保存 を実行し、適当な名前で保存します。
1. <font color="FireBrick">Googleスプレッドシートをブラウザで開いて、右上の共有ボタンから、共有設定をします。「一般的なアクセス」で、「リンクを知っている全員」を「編集者」に設定します。当然、ファイルのアクセス先は漏れないようにします。</font>（2024-04-18追記）
1. Googleスプレッドシートをブラウザで開くと、URL が https://docs.google.com/spreadsheets/d/1xxxx...xxx/edit#gid=0 などとなる。1xxxx...xxx がスプレッドシートのIDになるので、これを記録しておきます。
### 2-2. 問題用シートの準備
1. [問題用シート](https://docs.google.com/spreadsheets/d/1WvsnPxCj2bYDr4HjRIvlhPPIgWqUZEz2p5ebpKd5mBw/edit?usp=drive_link)を開いて、ファイル＞コピーして保存 を実行し、適当な名前で保存します。
1. 保存したGoogleスプレッドシートをブラウザで開きます。右上の「共有」をクリックして、「一般的なアクセス」で、「リンクを知っている全員」を「閲覧者」に設定して、「完了」をクリックします。
1. 拡張機能＞Apps Script でプログラムを開きます。開くだけでプログラムには手を触れません。
1. 左側の歯車マークをクリックし、「スクリプトプロパティを追加」をクリックします。
    * プロパティに「ChatGPTAPI」を入れて、値に取得してある OpenAI API の APIキーを入れます。
    * 同様にプロパティ「SlogID」を設定します。値は先程の記録シートの1で始まるスプレッドシートのID です。
    * 設定できたら「スクリプトプロパティを保存」をクリックし、保存を確かめしょう。
1. 右上の「デプロイ」をクリックします。「デプロイ」は、ウェブアプリとして利用できるようにすることを言います。初回なので「新しいデプロイ」を選択します。再び「デプロイ」をクリックします。
1. ウェッブアプリのURLをコピーしたうえで「完了」をクリックします。このときのウェブアプリのURLをもう一度表示したいときは、デプロイ＞デプロイの管理で再度表示できます。
### 2-3. ユーザー側の設定
1. 出題者は受講者にウェブアプリのURLを知らせましょう。しかし、セキュリティーのために、受講者の許諾が必要になります。その証拠に、受講者がそこにアクセスすると、警告のメッセージが表示されます。
1. まず、警告メッセージの「REVIEW PERMISSIONS」をクリックして、Googleのアカウントを選択します。一つしかない場合には、それを選びます。
1. 次に、「このアプリは Google で確認されていません」と表示されるので、左下の「詳細」をクリックして、下にスクロールしてください。すると「自動採点のサンプル：単語の意味（安全ではないページ）に移動」と表示されます。これをクリックします。名前、メールアドレスにアクセスできる旨、表示されます。「次へ」をクリックすると詳細な確認画面になるので、よく読んだ上で、「許可」をクリックして許可します。
1. 許可が終わると、問題、解答入力欄、送信ボタンが表示されます。解答入力欄に答案を入力して送信ボタンを押せば、ChatGPTによる得点とフィードバックが表示されます！出題者向けには記録用シートに結果が記録されていきます。
### 2-4. 問題の変更
　問題用シートは次のような構成になっています。表示・非表示が"1"(半角)となっている行だけ表示されるので、適宜選択します。言葉や、正解例・注意点は適宜書き換えてください。
 ![画像3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253484/02ad5801-e454-0aab-beb6-ebe31b920ca3.png)

## 3. スクリプト
* これらを実現したGASスクリプトを掲載します。
 ```javascript:コード.gs
// 左側の 「プロジェクトの設定」（歯車マーク）> スクリプト プロパティ で、次の２つのスクリプトプロパティーを設定、入力する
// 1) OpenAI の APIキー  : ChatGPTAPI
// 2) 結果を書き込むシート : SlogID

// スプレッドシートからデータを読み込む関数
function getSpreadsheetData() {
  const sheet  = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const range  = sheet.getDataRange();
  const values = range.getValues();
  return values.slice(1); // ヘッダー行を除外
}

// WebアプリのHTMLを提供する関数
function doGet() {
  Logger.log(Session.getActiveUser().getEmail());
  return HtmlService.createHtmlOutputFromFile('index');
}

// OpenAI APIを呼び出して文章を評価する関数
function evaluateDescription(word, limit, note, description) {
  // スクリプトプロパティで読み込む。
  const apiKey   = PropertiesService.getScriptProperties().getProperty('ChatGPTAPI');
  //ChatGPTのAPIのエンドポイントを設定
  const apiUrl = 'https://api.openai.com/v1/chat/completions';
  //ChatGPTに投げるメッセージを定義(ユーザーロールの投稿文のみ)
  const messages = [
    {
      'role': 'system',
      'content': `${word}を簡潔に${limit}字以内で説明してください。点数は0から100点で、100点満点です。評価は簡潔性、正確性、教育的価値を基に行います。文字数が${limit}字の10%を超える場合は減点対象です。90点未満の場合は、具体的な改善のためのヒントを30文字以内で提供します。また、解説は必要最小限にし、直接的かつ簡潔な表現を用いてください。重視すべきポイントは次の通りです：${note}。不要な詳細は省き、焦点を絞った80文字以内の回答を期待します。`
    },
    {
      'role': 'user',
      'content': `私の答えは、「${description}」です。どのような点数で、どのような改善点があるか教えてください。`
    },
  ];
  
  //OpenAIのAPIリクエストに必要なヘッダー情報を設定
  const headers = {
    'Authorization':'Bearer '+ apiKey,
    'Content-type': 'application/json',
    'X-Slack-No-Retry': 1
  };

  //ChatGPTモデルやトークン上限、プロンプトをオプションに設定
  const options = {
    'muteHttpExceptions' : true,
    'headers': headers, 
    'method': 'POST',
    'payload': JSON.stringify({
      // 'model': 'gpt-3.5-turbo',
      'model': 'gpt-4',
      'max_tokens' : 2048,
      'temperature' : 0.0,
      'messages': messages})
  };
  
  // OpenAIのChatGPTにAPIリクエストを送り、結果を変数に格納
  let response;
  try {
    const result = UrlFetchApp.fetch(apiUrl, options);
    response = JSON.parse(result.getContentText());
    Logger.log(response);
  } catch (error) {
    // エラーハンドリング: エラーログを記録し、適切なエラーメッセージを返す
    Logger.log("APIリクエストに失敗しました: " + error.toString());
    return "APIリクエストエラー";
  }

  // AIの応答から評価点数を抽出
  if (response.choices.length > 0 && response.choices[0].message) {
    // `content` を直接返す。数字であればそのまま、文字列であれば `.trim()` を使用
    var content = response.choices[0].message.content;
    content = typeof content === 'string' ? content.trim() : content.toString();
    logResult(word, content, description);
    return content;
  } else {
    // 応答が不適切な場合や期待したデータ構造でない場合の処理
    Logger.log("適切な応答が得られませんでした。");
    return "応答エラー";
  }
}

// HTML側でこの関数を呼び出すために公開
function submitDescription(word, limit, note, description) {
  return evaluateDescription(word, limit, note, description);
}

// 実行者のメールアドレス、実行時刻、単語、採点結果の得点をスプレッドシートに記録する関数
function logResult(word, content, description) {
  const userEmail = Session.getActiveUser().getEmail(); // 実行者のメールアドレスを取得
  const timestamp = new Date(); // 現在の時刻を取得
  const spreadsheet = SpreadsheetApp.openById(PropertiesService.getScriptProperties().getProperty('SlogID'));
  const sheet = spreadsheet.getActiveSheet();
  var score = extractScoreFromString(content);

  sheet.appendRow([timestamp, userEmail, word, score, description, content]); // スプレッドシートにデータを追加
}

// 文字列から得点を抽出する関数
function extractScoreFromString(inputString) {
  // 「〇〇点」というパターンにマッチする正規表現
  // \d+は1つ以上の数字にマッチ、点の前の数字を抽出
  const pattern = /(\d+)点/;
  const match = inputString.match(pattern);

  if (match) {
    // match[1]には、最初のカッチンググループ（\d+にマッチした部分）が含まれる
    return match[1]; // 抽出した数字（文字列として）を返す
  } else {
    // パターンにマッチしない場合は、0を返す
    return 0;
  }
}

 ```
 ```HTML:index.html
<!DOCTYPE html>
<html>
  <head>
    <title>単語の説明</title>
    <!-- スタイルを追加 -->
    <style>
      .row {
        display: flex; /* 横並びに要素を配置 */
        align-items: start; /* 上揃えで配置 */
        margin-bottom: 10px;
        padding: 10px; /* 内側の余白を設定 */
        background-color: #f2f2f2; /* 薄い灰色の背景色 */
      }
      .row:nth-child(odd) {
        background-color: #ffffff; /* 奇数行に白色の背景色を設定 */
      }
      .description {
        flex: 0 1 20%; /* 問題文の領域をより狭くする */
        word-wrap: break-word; /* 単語の途中でも折り返しを許可 */
        margin-right: 10px;
      }
      textarea {
        width: 300px; /* 入力欄の幅を固定 */
        margin-right: 10px;
        height: auto; /* 高さを自動調整 */
        resize: vertical; /* 垂直方向のみリサイズ可能に */
      }
      .result {
        flex: 1 1 40%; /* 結果表示欄の領域をより広くする */
        word-wrap: break-word; /* 長いテキストを折り返し */
      }
    </style>
  </head>
  <body>
    <h2>次の単語の説明を入力してください</h2>
    ※ あなたのメールアドレス、入力した解答、得点が記録されます。

    <HR>
    <div id="content"></div>

    <script>
      google.script.run.withSuccessHandler(function(data) {
        const content = document.getElementById('content');
        data.forEach(function([onOff, word, limit, note], index) {
        if (onOff.toString() === "1") {
            const row = document.createElement('div');
            row.className = 'row';
            row.innerHTML = `
              <div class="description">${word}の説明を${limit}字以内で答えよ。</div>
              <textarea id="desc-${index}" class="input" rows="3"></textarea>
              <button onclick="submitDescription('${word}', ${limit}, '${note}', document.getElementById('desc-${index}').value, 'result-${index}')">送信</button>
              <div id="result-${index}" class="result"></div>
            `;
            content.appendChild(row);
          }
        });
      }).getSpreadsheetData();

      function submitDescription(word, limit, note, description, resultId) {
        google.script.run.withSuccessHandler(function(result) {
          document.getElementById(resultId).textContent = result;
        }).evaluateDescription(word, limit, note, description);
      }
    </script>
  </body>
</html>
 ```
 * コードを書き換えたときには、デプロイ＞デプロイを管理＞ペンのマーク　とたどり、「バージョン」を「新バージョン」にして「デプロイ」ボタンをクリックします。同じURLのウェブアプリになります。
 
## 4. コメント
1. プライバシーについて
OpenAI API を使って入力したデータは学習には使われないとされています。（嘘をついていなければ。）メールアドレスはそもそも Open AI API に渡していないです。
　
1. 大学のドメイン名に基づくメールアドレスを利用している場合には、それでユーザーをフィルターして、外部の人は利用できないようにできます。また、そうすることで、個々の学生の管理ができます。
　
1. AIに流し込む文章がプログラム中に書かれていることがわかる。ここを変更すれば、いろいろなことができそう。