Google Apps ScriptとOpenAI APIで、社内イベント用にAI審査員付き共通点探しゲームをつくってみた

![AI審査結果表示デモ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591019/5c419fe1-00c3-4b61-a343-ae6359c9b541.gif)

## 1. はじめに

会社のパーティで懇親用に使う「AIを活用した共通点探しゲーム」を開発しました。
このゲームは各チームで共通点を見つけ出し、その希少性をAIが採点するというものです。
実際にゲームを実施してみたところ、予想以上に盛り上がり、
「AI審査員のコメントが面白すぎる！」と好評でした。

この記事では、どのようにゲームを設計・実装したか、
**特にGoogle Apps Script (GAS)やAI部分のプロンプトエンジニアリング**について
詳しく解説します。

**ご紹介している構成・コードをそのまま使えばベースとして動くものに**なると思いますので、
ぜひ自由にカスタマイズして、会社やコミュニティの親睦に活かしてみてください！

<details>
<summary>本記事で使用した技術スタック</summary>

- Google Apps Script (GAS)
- Google Forms & Spreadsheets
- OpenAI API (gpt-4o-mini)
- HTML/CSS/JavaScript

</details>

<details>
<summary>コバナシ</summary>

パーティ運営チームのメンバーの方から
「デジタルチームの力を少しでも知ってもらうためにも、
ぜひAIを使ったゲーム企画がやりたい！」
という熱い想いを聞き、今回の依頼を受けるに至りました。

</details>

### Thanks for the reference! :pray_tone1:

https://qiita.com/takasu_mtg/items/a35326c9b5b5af0427e3

## 2. 共通点探しゲームの紹介

このゲームは、各チームメンバー同士が共有する共通点を探し、
その中から最もユニークな共通点を1つ選んで提出します。
提出された共通点はAIが審査員になり、「希少性」を100点満点で評価します。

> 例：「全員iPhoneを持っている」より「全員左利き」の方が希少性が高い

<details>
<summary>反省</summary>

ゲーム当日、司会は別の方が担当されたのですが
”希少性を意識して” ”一つ選んで” というポイントを強調していただくと
ゲームの本質としてはよかったかも。。。
イマイチ伝わりきっていなかったのか、希少性が重視されていないかも、と思われる回答だったり
中にはいくつも回答してしまうチームが散見されました。

</details>

### ゲームのルール

1. 各チームで集まって、5人以上が共通する共通点を探してください
2. 5人以上が共通した内容の中から、選りすぐりの共通点を1つ選んでください
3. 共通点の希少性をAIが採点します！

時間配分は以下の通りです：
- 共通点探し：10分
- 共通点1点提出：5分  
- 審査＆発表：15分
- 結果発表：5分

## 3. AI審査の結果例

実際の結果を見てみましょう！AIが偉人になりきって評価コメントを出してくれました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591019/4602cd37-ad55-48fa-b03b-316a56ac65f8.png)


| 順位 | チーム名 | 共通点 | 平均点数 | 最高評価コメント | 最低評価コメント |
|:----:|:--------:|:------:|:--------:|:----------------:|:----------------:|
| 1 | チームA | この会社が大好き | 83.1 | 夢を持ち続ける人たちが集まる会社、まさに私の心を掴んで離さない！ (ウォルトディズニー) | 好きな会社があるのは素晴らしいが、私のようにあらゆるものを発明する者には無縁の話だね。 (アインシュタイン) |
| 2 | チームL | 多種多様なスポーツ経験者、メディア関連の仕事、眼が悪い、旅行好き | 80 | 多様性は創造の源泉だ！様々な経験が未来を豊かにする。 (ウォルトディズニー) | 眼が悪いのにスポーツを？視力の限界を越えるのは、まさに驚異だ。 (トーマスエジソン) |

## 4. ゲームの実装フロー

ゲームの実装は以下のようなフローを想定して行ないました：

```mermaid
graph LR
    A[Google Form入力] --> B[Spreadsheet蓄積]
    B --> C[GAS実行]
    C --> D[OpenAI API]
    D --> E[結果をSpreadsheetに書き込み]
    E --> F[結果発表]
    
    classDef googleClass fill:#c2e0ff,stroke:#6baed6
    classDef gasClass fill:#ffdbb8,stroke:#fd8d3c
    classDef apiClass fill:#d4ebbe,stroke:#74c476
    
    class A,B,E,F googleClass
    class C gasClass
    class D apiClass
```

### 4.1 フォーム入力画面

チームごとに共通点を入力するシンプルなGoogle Formを作成しました。

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591019/e6e535fe-3770-4bd6-8c12-e23b193b0ddd.jpeg">

### 4.2 審査結果表示

審査結果はGoogle Spreadsheetに格納され、プロジェクターで表示します。

## 5. Google Apps Scriptの実装

Google Forms、Spreadsheets、OpenAI APIを連携させるため、
Google Apps Script (GAS) を使用しました。

### 5.1 GASのコード構成

コードは役割ごとに分割し、テストもしやすい構成にしました。

<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591019/4a9c63e4-c477-47f0-93be-eb283214cf28.png">


```
/
├── updateTeamList.gs  # チーム一覧更新処理
├── main.gs            # メイン処理
├── prompt.gs          # プロンプト管理
├── test.gs            # 正常系テスト用コード
└── errorTest.gs       # 異常系テスト用コード
```

### 5.2 スクリプトコードの全容

<details><summary> :gear: updateTeamList.gs</summary>

```javascript

// updateTeamList.gs

function updateTeamDropdown() {
  try {
    Logger.log('チームドロップダウン更新開始');
    const form = FormApp.openById("1KU73PvC54ibprAFuyW1g1rzSBWBpVxR3XvFAkqs3-Xo");
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("チーム名一覧");
    
    if (!sheet) {
      Logger.log('エラー: "チーム名一覧"シートが見つかりません');
      return;
    }
    
    const values = sheet.getRange("A1:A" + sheet.getLastRow()).getValues();
    Logger.log('チーム数:', values.length);
    
    const items = values.flat().filter(String);
    const teamQuestion = form.getItems().filter(item => item.getTitle() === "チーム名を教えてください")[0];
    
    if (teamQuestion) {
      const dropdown = teamQuestion.asListItem();
      dropdown.setChoices(items.map(item => dropdown.createChoice(item)));
      SpreadsheetApp.getUi().alert("チーム名のプルダウンが更新されました！");
      Logger.log('ドロップダウン更新完了 - 選択肢数:', items.length);
    } else {
      Logger.log('エラー: チーム名の質問が見つかりません');
    }
  } catch (error) {
    Logger.log('ドロップダウン更新エラー:', error);
    throw error;
  }
}

```

</details>

<details><summary>updateTeamList.gs - 工夫した点</summary>

パーティ当日、いろいろな理由で人数の増減がありえました。
そのため、エンジニアではない運営メンバーでも
Google Formのチーム名の選択肢を増減させやすいよう
チーム一覧の編集→ボタン押下するだけで変更可能にしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591019/64d4c377-4b90-41ac-a98e-5a050fe3f30b.png)

---

</details>

<details><summary> :gear: main.gs</summary>

```javascript

// main.gs

// メインの処理を行う関数群
function onOpen() {
  const ui = SpreadsheetApp.getUi();
  ui.createMenu('共通点探しゲーム🎮️')
    .addItem('集計スタート❗️', 'fetchDataAndWriteToSpreadsheet')
    .addToUi();

    Logger.log('メニュー初期化完了');
}

/**
 * APIからデータを取得し、それをスプレッドシートに書き込む主要な関数
 */
function fetchDataAndWriteToSpreadsheet() {
    const content = getPromptContent();
    const jsonResponse = fetchApiData(content);
    writeDataToSheet(jsonResponse);
    SpreadsheetApp.getUi().alert('結果の集計が完了しました！');
}

```

</details>

<details><summary>main.gs - 工夫した点</summary>

スプレッドシートに「共通点探しゲーム＞集計スタート」というカスタムメニューを追加して
実行できるようにすることで、運営チームメンバーが使いやすいようにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591019/3215d35e-cf3a-407b-84c7-dc323027fbf9.png)

---

</details>

<details><summary> :gear: prompt.gs</summary>

```javascript

// prompt.gs

/**
 * 全チームの回答を集計シートから取得
 */
function getTeamResponses() {
    Logger.log(`=== getTeamResponses 開始 ===`);
    try {
        // スプレッドシートを開く
        const ss = SpreadsheetApp.getActiveSpreadsheet();
        // 「集計」シートから値を取得
        const summarySheet = ss.getSheetByName('集計');
        const data = summarySheet.getDataRange().getValues();

        Logger.log(`データ取得成功 - 行数: ${data.length}`);
        Logger.log(`取得データ:\n${JSON.stringify(data, null, 2)}`);

        // 回答を格納する配列を初期化
        let teamResponses = [];
        for (let i = 1; i < data.length; i++) {
            // チーム名と共通点がある場合に処理を行う
            if (data[i][1] && data[i][2]) {
                teamResponses.push(`チーム名:${data[i][1]}、共通点：${data[i][2]}`);
            }
        }
        Logger.log(`チーム回答作成完了 - チーム数: ${teamResponses.length}`);
        Logger.log(`チーム回答:\n${JSON.stringify(teamResponses, null, 2)}`);

        // すべてのチームの回答を連結して返却
        return teamResponses.join('、');
    } catch (error) {
        Logger.log(`getTeamResponses エラー: ${error.toString()}`);
        throw error;
    }
}

/**
 * プロンプトテンプレートに回答を埋め込み
 */
function getPromptContent() {
    Logger.log(`=== getPromptContent 開始 ===`);
    try {
        // スプレッドシートを開く
        const ss = SpreadsheetApp.getActiveSpreadsheet();
        // プロンプトシートから値を取得
        const promptSheet = ss.getSheetByName('プロンプト');
        let content = promptSheet.getRange('A1').getValue();
        Logger.log(`プロンプトテンプレート取得完了 - 長さ: ${content.length}`);

        // 各チームの回答を取得
        const teamResponses = getTeamResponses();
        // {{各チームの回答}}を置換
        content = content.replace('{{各チームの回答}}', teamResponses);
        Logger.log(`プロンプト作成完了 - 長さ: ${content.length}`);
        Logger.log(`最終プロンプト内容:\n${content}`);

        return content;
    } catch (error) {
        Logger.log(`getPromptContent エラー: ${error.toString()}`);
        throw error;
    }
}

function getApiKey() {
  const scriptProperties = PropertiesService.getScriptProperties();
  const apiKey = scriptProperties.getProperty('GPT_API_KEY');
  
  if (!apiKey) {
    throw new Error('APIキーが設定されていません。プロジェクトのプロパティでGPT_API_KEYを設定してください。');
  }
  
  return apiKey;
}

/**
 * OpenAI APIを呼び出しデータを取得
 */
function fetchApiData(content) {
    Logger.log(`=== fetchApiData 開始 ===`);
    try {
        const url = 'https://api.openai.com/v1/chat/completions';
        const data = {
            "model": "gpt-4o-mini",
            // "model": "gpt-4o",
            "messages": [{"role": "user", "content": content}],
            "temperature": 0.7,
            "response_format": { "type": "json_object" }
        };
        Logger.log(`APIリクエスト内容:\n${JSON.stringify(data, null, 2)}`);
        
        const options = {
            method: 'post',
            headers: {
                "Authorization": `Bearer ${getApiKey()}`,
                "Content-Type": "application/json"
            },
            payload: JSON.stringify(data),
            muteHttpExceptions: true,
            timeout: 30000
        };

        Logger.log(`API呼び出し開始 - ${url}`);
        const response = UrlFetchApp.fetch(url, options);
        const responseCode = response.getResponseCode();
        Logger.log(`APIレスポンス取得 - ステータスコード: ${responseCode}`);
        Logger.log(`APIレスポンス本文:\n${response.getContentText()}`);
        
        if (responseCode !== 200) {
            throw new Error(`API Error: ${responseCode} - ${response.getContentText()}`);
        }
        
        return JSON.parse(response.getContentText());
    } catch (error) {
        Logger.log(`fetchApiData エラー: ${error.toString()}`);
        SpreadsheetApp.getUi().alert('APIエラーが発生しました。もう一度お試しください。');
        throw error;
    }
}

/**
 * API結果をスプレッドシートに書き込み
 */
function writeDataToSheet(jsonResponse) {
    Logger.log(`=== writeDataToSheet 開始 ===`);
    try {
        // スプレッドシートを開く
        const ss = SpreadsheetApp.getActiveSpreadsheet();
        const sheet = ss.getSheetByName('審査結果');
        // シートの内容をクリア
        sheet.getRange('A:K').clearContent();
        Logger.log(`シートクリア完了`);

        let messageContent = JSON.parse(jsonResponse.choices[0].message.content);
        Logger.log(`JSONパース完了 - チーム数: ${Object.keys(messageContent).length}`);
        Logger.log(`パース結果:\n${JSON.stringify(messageContent, null, 2)}`);

        // ヘッダーを作成し、シートに追加
        const headers = createHeaders(messageContent);
        sheet.appendRow(headers);
        Logger.log(`ヘッダー書き込み完了: ${headers.join(', ')}`);

        // 各チームのデータを準備
        const allTeams = prepareDataForTeams(messageContent, headers.slice(6));
        Logger.log(`チームデータ準備完了 - チーム数: ${allTeams.length}`);
        Logger.log(`準備したデータ:\n${JSON.stringify(allTeams, null, 2)}`);

        // データをシートに書き込み
        writeTeamsToSheet(allTeams, sheet);
        Logger.log(`チームデータ書き込み完了`);
    } catch (error) {
        Logger.log(`writeDataToSheet エラー: ${error.toString()}`);
        throw error;
    }
}

/**
 * ヘッダーを作成する関数
 */
function createHeaders(messageContent) {
    const headers = ["順位", "チーム名", "共通点", "平均点数", "点数を最も高くつけた人のコメント", "点数を最も低くつけた人のコメント"];
    const firstTeam = Object.keys(messageContent)[0];
    const judges = Object.keys(messageContent[firstTeam]["審査員ごとの点数"]);
    return headers.concat(judges);
}

/**
 * 各チームのデータを作成する関数
 */
function prepareDataForTeams(messageContent, judges) {
    let allTeams = [];
    for (let team in messageContent) {
        // 各チームの情報を配列に格納
        const row = createTeamRow(messageContent, team, judges);
        allTeams.push({
            "teamName": team,
            "sharedPoint": messageContent[team]["共通点"],
            "averageScore": messageContent[team]["平均点数"],
            "highestComment": messageContent[team]["点数を最も高くつけた人のコメント"],
            "lowestComment": messageContent[team]["点数を最も低くつけた人のコメント"],
            "scores": row.slice(5),
            "dataRow": row
        });
    }
    // 平均点数で降順にソート
    return allTeams.sort((a, b) => b.averageScore - a.averageScore);
}

/**
 * チームの行を作成する関数
 */
function createTeamRow(messageContent, team, judges) {
    let row = [];
    row.push(team, messageContent[team]["共通点"], messageContent[team]["平均点数"],
              messageContent[team]["点数を最も高くつけた人のコメント"], messageContent[team]["点数を最も低くつけた人のコメント"]);
    // 審査員の点数を行に追加
    row = row.concat(judges.map(judge => messageContent[team]["審査員ごとの点数"][judge]));
    return row;
}

/**
 * チームのデータをシートに書き込む関数
 */
function writeTeamsToSheet(allTeams, sheet) {
    Logger.log(`=== writeTeamsToSheet 開始 ===`);
    try {
        let rank = 1;
        for (let i = 0; i < allTeams.length; i++) {
            // 同じ点数の場合は順位を維持、異なる場合は順位をインクリメント
            if (i > 0 && allTeams[i].averageScore < allTeams[i - 1].averageScore) {
                rank++;
            }
            // ランキングとデータを連結して新しい行を作成
            const row = [rank].concat(allTeams[i].dataRow);
            Logger.log(`行データ作成 - チーム: ${allTeams[i].teamName}, 順位: ${rank}`);
            // 行をシートに追加
            sheet.appendRow(row);
        }
        Logger.log(`順位付けとデータ書き込み完了 - 処理チーム数: ${allTeams.length}`);
    } catch (error) {
        Logger.log(`writeTeamsToSheet エラー: ${error.toString()}`);
        throw error;
    }
}

```

</details>

<details><summary>prompt.gs - 工夫した点</summary>

openAI APIのAPIキーをハードコーディングするのではなく
GASの設定（歯車マーク）にて
「スクリプト プロパティ」として定義することで、

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591019/f710bd7f-eaa0-4d62-8fb9-7d02a62b3970.jpeg">

↓

```javascript
const apiKey = scriptProperties.getProperty('GPT_API_KEY');
```

という方法で取得するようにしています。
セキュリティ面と、テストコードでも使い回せるよう利便性の面でベターと考えています。

---

</details>

<details><summary> :gear: test.gs</summary>

```javascript

function testApiKey() {
  const scriptProperties = PropertiesService.getScriptProperties();
  const apiKey = scriptProperties.getProperty('GPT_API_KEY');
  Logger.log('APIキー設定状況: %s', apiKey ? '設定済み' : '未設定');
  // APIキーの最初の数文字を表示して確認
  if (apiKey) {
    Logger.log('APIキーの最初の10文字: %s...', apiKey.substring(0, 10));
  }
}

function testGetTeamResponses() {
  const responses = getTeamResponses();
}

function testGetPromptContent() {
  const content = getPromptContent();
}

function testFetchApiData() {
  const content = getPromptContent();
  const response = fetchApiData(content);
}

/**
 * すべてのテストを順番に実行
 */
function runAllTests() {
  Logger.log(`=== 全テスト実行開始 ===`);
  
  try {
    Logger.log(`1. APIキー確認テスト`);
    testApiKey();
    
    Logger.log(`2. チーム回答取得テスト`);
    testGetTeamResponses();
    
    Logger.log(`3. プロンプト生成テスト`);
    testGetPromptContent();
    
    Logger.log(`4. API実行テスト`);
    testFetchApiData();
    
    Logger.log(`=== 全テスト実行完了 ===`);
  } catch (error) {
    Logger.log(`テスト実行エラー: ${error.toString()}`);
    throw error;
  }
}

```

</details>

<details><summary> :gear: errorTest.gs</summary>

```javascript

function testMissingSheet() {
  // 存在しないシート名でのエラーハンドリングをテスト
  try {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("存在しないシート");
    if (!sheet) {
      throw new Error('シートが見つかりません');
    }
    // エラーハンドリングの確認
  } catch (error) {
    Logger.log('シート不在テスト: OK');
  }
}

function testInvalidDataFormat() {
  // 異常なデータ形式での動作確認
  const invalidData = {
    malformed: "data"
  };
  
  try {
    writeDataToSheet(invalidData);
  } catch (error) {
    Logger.log('データ形式異常テスト: OK');
  }
}

/**
 * テスト実行のメインメニューを追加
 */
function onOpen() {
  const ui = SpreadsheetApp.getUi();
  
  // 既存のメニュー
  ui.createMenu('共通点探しゲーム🎮️')
    .addItem('集計スタート❗️', 'fetchDataAndWriteToSpreadsheet')
    // テストメニューをサブメニューとして追加
    .addSeparator()
    .addSubMenu(ui.createMenu('🧪 テスト実行')
      .addItem('全テスト実行', 'runAllTests')
      .addSeparator()
      .addSubMenu(ui.createMenu('APIエラーテスト')
        .addItem('不正APIキー', 'runInvalidApiKeyTest')
        .addItem('不正レスポンス', 'runInvalidResponseTest'))
      .addSubMenu(ui.createMenu('メモリ制限テスト')
        .addItem('大量データ', 'runLargeDataTest')
        .addItem('スタックオーバーフロー', 'runStackOverflowTest')
        .addItem('大きなJSON', 'runLargeJsonTest')))
    .addToUi();
}

/**
 * 全テスト実行（危険なテストは除外）
 */
function runAllTests() {
  Logger.log('=== 全テスト実行開始 ===');
  try {
    testMissingSheet();
    testInvalidDataFormat();
    Logger.log('=== 全テスト実行完了 ===');
  } catch (error) {
    Logger.log(`テスト失敗: ${error.toString()}`);
    throw error;
  }
}

/**
 * 個別テスト実行用のラッパー関数群
 */
function runMissingSheetTest() {
  runSpecificTest('testMissingSheet');
}

function runInvalidDataFormatTest() {
  runSpecificTest('testInvalidDataFormat');
}

// APIエラーテスト用
function runInvalidApiKeyTest() {
  runSpecificTest('testApiError', 'invalidKey');
}

function runInvalidResponseTest() {
  runSpecificTest('testApiError', 'invalidResponse');
}

// メモリ制限テスト用
function runLargeDataTest() {
  runSpecificTest('testMemoryLimit', 'largeData');
}

function runStackOverflowTest() {
  runSpecificTest('testMemoryLimit', 'stackOverflow');
}

function runLargeJsonTest() {
  runSpecificTest('testMemoryLimit', 'largeJson');
}

/**
 * テスト実行用のユーティリティ関数
 */
function runSpecificTest(testName, testType = null) {
  try {
    Logger.log(`=== ${testName} ${testType || ''} 開始 ===`);
    if (testType) {
      this[testName](testType);
    } else {
      this[testName]();
    }
    Logger.log(`=== ${testName} ${testType || ''} 正常終了 ===`);
  } catch (error) {
    Logger.log(`テストエラー: ${error.toString()}`);
    throw error;
  }
}

// APIエラーテスト用の実装
function testApiError(testType = 'invalidKey') {
  Logger.log(`=== APIエラーテスト(${testType}) 開始 ===`);
  
  switch(testType) {
    case 'invalidKey':
      // 不正なAPIキーでのテスト
      try {
        const originalKey = PropertiesService.getScriptProperties().getProperty('GPT_API_KEY');
        PropertiesService.getScriptProperties().setProperty('GPT_API_KEY', 'invalid_key');
        
        fetchApiData('test content');
        
        // エラーが発生しなかった場合は失敗
        throw new Error('不正なAPIキーでエラーが発生しませんでした');
      } catch (error) {
        if (error.message.includes('API Error')) {
          Logger.log('不正APIキーテスト: OK');
        } else {
          throw error;
        }
      } finally {
        // 元のAPIキーを復元
        PropertiesService.getScriptProperties().setProperty('GPT_API_KEY', originalKey);
      }
      break;

    case 'invalidResponse':
      // 不正なレスポンス形式テスト
      try {
        const mockResponse = {
          choices: [
            {
              message: {
                content: 'Invalid JSON format'  // 正しいJSONではない
              }
            }
          ]
        };
        writeDataToSheet(mockResponse);
        throw new Error('不正なレスポンス形式でエラーが発生しませんでした');
      } catch (error) {
        if (error.message.includes('JSON')) {
          Logger.log('不正レスポンス形式テスト: OK');
        } else {
          throw error;
        }
      }
      break;
      
    default:
      throw new Error(`未知のテストタイプ: ${testType}`);
  }
  
  Logger.log(`=== APIエラーテスト(${testType}) 完了 ===`);
}

// メモリ制限テスト用の実装
function testMemoryLimit(testType = 'largeData') {
  Logger.log(`=== メモリ制限テスト(${testType}) 開始 ===`);
  
  switch(testType) {
    case 'largeData':
      // 大量データ生成テスト
      try {
        // 大量のチームデータを生成
        const ROW_COUNT = 50000;
        const COLUMN_COUNT = 20;
        Logger.log(`大量データ生成開始: ${ROW_COUNT}行 x ${COLUMN_COUNT}列`);

        const largeTeamResponses = [];
        for (let i = 0; i < 1000; i++) {
          largeTeamResponses.push(`チーム名:チーム${i}、共通点：テストデータ${i}`);
        }
        
        // スプレッドシートに大量データを書き込み
        const ss = SpreadsheetApp.getActiveSpreadsheet();
        const sheet = ss.getSheetByName('集計') || ss.insertSheet('集計');

        // 大量のデータを一度に書き込もうとする
        const largeData = Array(ROW_COUNT).fill().map((_, rowIndex) => {
          return Array(COLUMN_COUNT).fill().map((_, colIndex) => {
            // より長いテキストデータを生成
            return `チーム${rowIndex}_データ${colIndex}_${Array(100).fill('test').join('')}`;
          });
        });

        Logger.log('大量データ生成: OK');

        // ヘッダー行を考慮して2行目から書き込み開始
        const startRow = 2;
        sheet.getRange(startRow, 1, largeData.length, largeData[0].length).setValues(largeData);

        // テスト後のクリーンアップ
        sheet.clear();
      } catch (error) {
        if (error.message.includes('exceeded') || error.message.includes('limit')) {
          Logger.log('期待されたメモリ制限エラーが発生: OK');
        } else {
          throw error;
        }
      }
      break;

    case 'stackOverflow':
      // スタックオーバーフローテスト
      try {
        function recursiveFunction(depth) {
          return recursiveFunction(depth + 1);
        }
        recursiveFunction(1);
        throw new Error('スタックオーバーフローが発生しませんでした');
      } catch (error) {
        if (error.message.includes('stack') || error.message.includes('maximum')) {
          Logger.log('スタックオーバーフローテスト: OK');
        } else {
          throw error;
        }
      }
      break;

    case 'largeJson':
      // 大きなJSONオブジェクト生成テスト
      try {
        let largeObject = {};
        for (let i = 0; i < 100000; i++) {
          largeObject[`key${i}`] = `value${i}`.repeat(1000);
        }
        JSON.stringify(largeObject);
        throw new Error('大きなJSONオブジェクトでエラーが発生しませんでした');
      } catch (error) {
        if (error.message.includes('memory') || error.message.includes('exceeded')) {
          Logger.log('大きなJSONオブジェクトテスト: OK');
        } else {
          throw error;
        }
      }
      break;
      
    default:
      throw new Error(`未知のテストタイプ: ${testType}`);
  }
  
  Logger.log(`=== メモリ制限テスト(${testType}) 完了 ===`);
}

```

</details>

<details><summary>test.gs & errorTest.gs - 工夫した点</summary>

100人以上が集まる、会社の正式な集まりなので
本番でなにかあってはマズいです。
ゲーム開発時はもちろん、本番当日にも会場でテストしやすいように考慮しました。

また、スプレッドシートに追加できるカスタムメニューの
入れ子構造の書き方の実践も兼ねて、メニューからボタンで実行できるようにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591019/87fff094-5464-43c6-9087-5348ed7d3072.png)

</details>

## 6. プロンプトエンジニアリング

今回最も苦労し、工夫したのはプロンプト設計です。
**合計6つのバージョンのプロンプトを試行錯誤**し、
AI審査員を各偉人に成りきらせ、バラエティ豊かな評価コメントを返すよう調整しました。

### 6.1 プロンプトの変遷

<details>
<summary> :ng: 最初のプロンプト</summary>

```
役割：あなたはイベントの審査員です。
目的：以下についてそれぞれ「共通点の希少性を100点満点で評価せよ」希少性が高いものを100点とし、低いものは0点とせよ。
前提：各審査員の評価点数の合計値は他の共通点と重複しないようにせよ。レスポンスはjson形式にせよ。チームごとに「チーム名、共通点、審査員名ごとの点数、平均点数、点数を最も高くつけた人のコメント、点数を最も低くつけた人のコメント」の配列を返却せよ。「審査員名ごとの点数」は、"審査員ごとの点数":{"ウォルトディズニー": 80,"ナイチンゲール": 80}のように返却せよ。審査員は「ウォルトディズニー、レオナルドダヴィンチ、トーマスエジソン、アインシュタイン、ナイチンゲール」とし、それぞれの人物像をもとに本人が言いそうなコメントや、偉人の名言のような形式で評価せよ。ミッキーマウスならディズニーなど、関連性が高いものについては、贔屓の点数をつけて良い。審査員のコメントはバラエティー的に面白くしてほしい。「点数を最も高くつけた人のコメント」と「点数を最も低くつけた人のコメント」はそれぞれ誰がコメントしたかをコメントの後に括弧書きで表記せよ。
{{各チームの回答}}
```

</details>

<details>
<summary>最初のプロンプトの問題点・課題と改善方針</summary>

1. レスポンスの形式指定がざっくりしているため、実際のレスポンスの形式にブレがあり、処理エラーとなるケースがあった
    1. → **レスポンス形式を明確に指定しよう！**
1. チームごとに同じ合計点数が多く、同じ順位がたくさん出てしまっていた
    1. → **点数を小数点以下までつけるよう指定しよう！**
1. 各偉人の回答が似たりよったり
    1. → **より細かく指定しよう！**

</details>

↓

<details>
<summary> :ok: 最終的なプロンプト</summary>

```
役割：あなたはイベントの審査員です。
目的：以下についてそれぞれ「共通点の希少性を100点満点で評価せよ」。希少性が高いものを100点とし、低いものは0点とせよ。
前提：各審査員の評価点数の合計値は他の共通点と重複しないようにせよ。また、小数点以下第一位まで評価してよい（例：82.5点）。平均点数はなるべく被らないように。レスポンスはjson形式で、以下の形式で返却せよ：

{
  "チームA": {
    "共通点": （共通点）,
    "審査員ごとの点数": {
      "ウォルトディズニー": （点数）,
      "レオナルドダヴィンチ": （点数）,
      "トーマスエジソン": （点数）,
      "アインシュタイン": （点数）,
      "ナイチンゲール": （点数）
    },
    "平均点数": （点数）,
    "点数を最も高くつけた人のコメント": （その人物らしい熱烈な賞賛）,
    "点数を最も低くつけた人のコメント": （その人物の代表的な業績や特徴を絡めた皮肉や風刺）"
  }
}

このような形式で各チームの評価を行い、必ずチーム名をキーとしたオブジェクト形式で返却せよ。
審査員は「ウォルトディズニー、レオナルドダヴィンチ、トーマスエジソン、アインシュタイン、ナイチンゲール」とし、それぞれの人物像をもとに本人が言いそうなコメントや、偉人の名言のような形式で評価せよ。
ミッキーマウスならディズニーなど、関連性が高いものについては、贔屓の点数をつけて良い。
審査員のコメントはバラエティー的に面白くしてほしい。
高評価は、その人物らしい代表的な業績や特徴を絡めた熱烈な賞賛を。低評価は、その人物の代表的な業績や特徴を絡めた皮肉や風刺を。いずれも代表的な業績や特徴の固有名詞を入れて。
人物同士で内容が傾向的にも被らないように。同じ人物でもチームごとに内容が傾向的にも被らないように。
男性は男性口調で、女性は女性口調で。

「点数を最も高くつけた人のコメント」と「点数を最も低くつけた人のコメント」はそれぞれ誰がコメントしたかをコメントの後に括弧書きで表記せよ。
各チームの回答：
{{各チームの回答}}
```

</details>

### 6.2 プロンプト設計のポイント

1. **出力形式の明確化**: JSON形式で統一し、後処理を容易にしました
2. **審査員の個性付け**: 会社の実在人物を審査員として設定（会社情報のマスキングのため、紹介しているプロンプトは偉人になっています）
3. **コメントの多様性**: 同じ審査員でもチームごとに異なるコメントになるよう指定
4. **評価基準の明確化**: 希少性を評価基準とし、点数の振り幅を持たせました

### 6.3 モデル選定

- 当初は**gpt-4o**で高品質な結果を目指しましたが、**賢すぎるのか評価がかなり厳しく低得点に**なってしまいました。また比較的、応答時間が長く、イベント進行に支障をきたす懸念がありました
- 最終的には**gpt-4o-mini**を採用し、品質と応答速度のバランスを取りました

### 6.4 かかった料金

- かかった料金は0.24ドル（約38円）でした。一番多くリクエストを試した開発時でも0.17ドルなので、これくらいであればバンバン使えるなー、という感じです
- 2025年2月時点で、OpenAI APIは最初の契約時に5ドル（約800円）分の利用料を買い切ることになっています。今回、かなり余ったので、今後また別の個人開発で使っていきたいと思います

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591019/80263923-5756-440e-9397-d39ff664bc7a.png)

## 7. 工夫した点・学んだこと

### 工夫した点

- **プロンプトの試行錯誤**: 審査員のキャラクター性を引き出すため、複数のプロンプトバージョンをテスト
- **エラーハンドリング**: API呼び出しのタイムアウトや失敗に備えて対策
- **テスト用スクリプト**: 本番前に動作確認ができるテスト環境を用意

### 学んだこと

- プロンプトエンジニアリングの重要性
- GASとOpenAI APIの連携方法
- イベント進行におけるルール周知の大切さ

## 8. さいごに

今回のAIを活用した共通点探しゲームは、技術的にはシンプルながらも、参加者の反応は非常に良く、
会社の親睦会を大いに盛り上げることができました。
プロンプトエンジニアリングを工夫することで、
限られたAPI予算内でも十分に楽しめるゲームが作れることを実感しました。

今後は他のAIモデルを試したり、審査員のキャラクターをさらに多様化したりして、
より楽しいゲームに発展させていきたいと思います。

---

### 参考資料

- [OpenAI API Reference](https://platform.openai.com/docs/api-reference)
- [Google Apps Script ドキュメント](https://developers.google.com/apps-script)
- [プロンプトエンジニアリングガイド](https://www.promptingguide.ai/)