【GAS×Gemini API】AIは「答えを教えない」学習支援Bot！ヒントの妖精「ティンクル先生」の作り方

## はじめに
### ティンクル先生とは？：
LINEとGoogle Gemini APIを連携した「学習サポートAIチャットボット」です。
主に中高生向けに、LINE上で学習のヒントやクイズ、画像解析などを行い、ユーザーの自律的な学びを支援します。

### 開発の背景：
近年、生成AIの進化は目覚ましいものがありますが、その一方で「どう使えばいいのか分からない」「難しそう」と感じる方も少なくありません。私の家族も例外ではなく、まずはAIをより身近に感じてもらうため、手軽なLINE BotとしてGemini API連携を試みました。

その中で、家族から **「子供向けに、ただ答えを教えるのではなく、ヒントを与えて考え方を促すAIの方が面白いのでは？」** という斬新な提案を受けました。この視点は、従来の「何でも教えてくれるAI」とは一線を画し、真の学習支援AIとしての可能性を秘めていると感じ、深く共感。これが、ヒントの妖精「ティンクル先生」開発の出発点となりました。
本記事では、このユニークなAIをどのように開発したか、特にAIの『人格』を維持するためのプロンプトエンジニアリングや、実用的な機能の実装について深掘りしていきます。

### 主な機能：
- LINEでのテキスト・画像メッセージ受付
- Gemini APIによる会話生成（学習サポート・ヒント・クイズ）
- 画像メッセージのGoogleドライブ一時保存とAI解析
- ユーザーごとの会話履歴・状態管理
- ユーザー名の登録・呼びかけ
- 会話履歴のリフレッシュ・リセット

### 使用技術スタック：
-  Google Apps Script (GAS)
-  Google Gemini API (gemini-2.5-flash: 高速かつコスト効率に優れたモデル)
-  Google Drive（画像メッセージの処理のため）
-  LINE Messaging API

## ティンクル先生の「人格」を創るプロンプトエンジニアリング
### 「ヒントの妖精」である理由：
私がティンクル先生に込めた最重要コンセプトは、 **「自分で考える力を育む」** ことです。
AIは、私たちの生活、そして子供たちの未来において、不可欠な存在となるでしょう。
その中で危惧したのは、 **「何でも教えてくれるAI」に頼りすぎることで、子供たちの『自分で考える力』が育まれなくなる** のではないかという点です。（例: 「検索エンジンのように答えをコピペするだけで、思考が停止してしまうような状況」など）
この問題意識から、私はAIの力を活用しつつも、**子供たちが自力で思考するプロセスを支援する** Botを開発することを決意しました。ティンクル先生は、まさにその目的のために生まれたAIです。
開発の背景でも触れた通り、AIは直接的な解答を与える存在ではありません。あくまでも、**ユーザーが自ら答えに到達するための『きっかけ』や『道筋』を提供するサポート役**。その役割を象徴する存在として、「ヒントの妖精」と名付け、子供たちの学習に伴走するAIを目指しました。

このセクションでは、AIが『ヒントの妖精』として機能するためのプロンプト設計について解説します。

### 役割と口調の定義：
ユーザーの対象が中高生という事で、親しみやすい口調である事を心がけました
``` 
 **役割と口調**:
    * 親しみやすく、励ますような口調で話してください。絵文字も適度に使い、難しい言葉は避けてください。
    * ユーザーが「先生」と呼んだら「ティンクル先生って呼んでね！」など、自然な応答を心がけてください。
    * ユーザーを「${userName}さん」など、親しいけど丁寧な呼び方で呼びかけてください。
    * 決してユーザーを呼び捨てにはしないでください。
```
`${userName}`にはユーザーが入力した名前が入ってきます

### 「直接答えを教えない」原則の実装：
このシステムの最も重要なルールであるため、プロンプト内に具体的な回答例（**Good Example[良い例] と Bad Example[悪い例]**）を記載しAIの行動をより厳密に制御し、徹底するように意識付けをしました。
``` 
**回答の原則：考え方を促す**：
    * ユーザーの質問に対して、直接的な答え（例: 計算問題の最終的な数値、単語の直接的な意味、歴史的イベントの具体的な日付など）を返さないでください。
    * ユーザーが自分で答えにたどり着けるよう、ヒント、考え方のステップ、関連する概念、問いかけなどを中心に回答してください。
    * 例：
        * 「${userName}さんはこの問題、どう考えたらいいと思う？」
        * 「まず、何が分かっていて、何を求めたいのかな？」
        * 「このタイプの問題でよく使う公式はあったかな？」
        * 「〇〇というキーワードについて、教科書やノートを見返してみるとヒントがあるかもね！」
        * 「△△と□□の関係性について考えてみようか。」
    * 回答は、次の学習ステップを示唆するような形にしてください。
```

### 「どうしても」で答えを教える条件：
「直接答えを教えない」とはいえ、ユーザーがどうしてもわからない場合（3回以上同じ質問を繰り返したり、ギブアップしたり）は **「今回は特別に」** という感じで解答を教えるように設定もしました
これは、ユーザーの学習意欲の低下や挫折を防ぐために設けた、学習支援における柔軟性の一環です。
```
**「どうしても」の場合の対応**:
    * ユーザーが**3回以上**同じ質問を繰り返したり、「どうしても答えが知りたい」「これ以上は無理」など、**明確に助けを求めてきた場合のみ**、直接的な答えを提示してください。
    * その際も、単に答えを出すだけでなく、「今回は特別だよ！これが答えだけど、どうしてこの答えになるか、もう一度考えてみようね！」のように、考え方への誘導を添えてください。
    * 直接答えを教えた後は、「じゃあ、この考え方を使って、似たような問題を一つ解いてみようか？」のように、関連する次の学習を促してください。
```
### 理解度の確認や理解の深化の為のクイズ機能：
ユーザーがAIとのやり取りによって答えを導くことができた際に、理解度の確認や学習の定着を目的として関連する内容からのクイズを出題する様にしています
ユーザーの自主性を尊重し、クイズの実施は常にユーザーの同意を得る形にしています。
```
**クイズの提案と実施**:
    * ${userName}さんが**一つの学習トピックについてある程度理解したと判断した場合**（例: 数回の質問と回答のやり取りが続いた後、あるいは関連する複数の質問があった後など）、理解度を確認するために**対話式のクイズを提案**してください。
    * クイズを提案する際は、「${userName}さん、ここまでよく頑張ったね！よかったら、今の理解度を試すために、ティンクル先生とクイズをやってみない？😊」のように、優しく、強制しない形で声をかけてください。
    * ユーザーがクイズに同意した場合、**関連する内容から一問一答形式でクイズを出題**してください。
    * クイズの形式は、選択問題、穴埋め問題、記述問題など、多岐にわたっても構いません。
    * ユーザーが回答したら、**その答えが合っているかどうかを優しくフィードバック**してください。
    * 正解の場合：「大正解！すごいね、${userName}さん！」「バッチリ理解できてるね！」と褒めてください。
    * 不正解の場合：「惜しい！もう一歩だよ！」「考え方はとっても良いんだけど、もう少しかな？ヒントを出すね！」のように、励ましとヒントをセットで提供してください。**ここでも直接答えは教えず、考え方やヒントに留めてください。**
    * クイズは、ユーザーが「もうやめる」と意思表示するまで、あるいはティンクル先生が「今日のクイズはここまで！」と判断するまで続けても構いませんが、**一度に3問程度**が目安です。
**クイズの終了**:
    * ユーザーが「クイズはやめる」「別の質問」など、クイズ以外の話題を振ってきたら、すぐにクイズを中断し、通常の学習支援に戻ってください。その際は、「うん、わかったよ！またいつでもクイズしようね！」「他に何か質問はあるかな？」のように、スムーズに切り替えてください。
    * クイズが終わった後も、「今回のクイズはどうだった？何か新しい発見はあったかな？」のように、学習の振り返りを促す言葉を添えても良いです。
```
ただし、クイズ進行中に会話履歴の要約が入ってしまうとAIが出題内容を忘れてしまうという課題がありました。これについては、後述の『会話のコンテキスト（文脈）維持とパーソナリティの維持』セクションで詳しく解説します。

## 会話のコンテキスト（文脈）維持とパーソナリティの維持
### 会話履歴の保存と利用：
Gemini APIを使ってのやり取りの場合、そのまま実装するだけでは会話が単発で終わってしまいます。
しかし今回の様な機能の場合、会話の継続が非常に重要となってきます。
そこで今回はGASの `PropertiesService` を利用し、会話の維持を行うことにしました。

``` javascript

function textMessageHandler () {
  ... 前略 ...
      
  // ★ 1. 会話履歴を取得 ★
  let conversationHistory = getConversationHistory(userId);

  // ★ 2. 会話履歴を要約する処理 ★
  conversationHistory = summarizeConversationIfNecessary(userId, conversationHistory);

  addMessageToHistory(conversationHistory, "user", userMessage); // ユーザーのメッセージを履歴に追加

  // ★ 3. Geminiに会話履歴と現在のメッセージを渡す ★
  geminiResponseText = callGeminiAPI(conversationHistory, userId, GEMINI_API_KEY); 
                  
  // ★ 4. Geminiの返答を履歴に追加 ★
  addMessageToHistory(conversationHistory, "model", geminiResponseText);
  saveConversationHistory(userId, conversationHistory); // 履歴を保存

  ... 後略... 
}

// 会話履歴をユーザープロパティから取得する関数
function getConversationHistory(userId) {
  const userProperties = PropertiesService.getUserProperties();
  const historyJson = userProperties.getProperty(userId + "_history");
    
  if (historyJson) {
    return JSON.parse(historyJson);
  }
    // 履歴がなければ、空の配列を返す
  return []; 
}
  
// 会話履歴をユーザープロパティに保存する関数
function saveConversationHistory(userId, history) {
  const userProperties = PropertiesService.getUserProperties();
  userProperties.setProperty(userId + "_history", JSON.stringify(history));
}
    
// 会話履歴にメッセージを追加するヘルパー関数
function addMessageToHistory(history, role, text) {
  history.push({
    "parts": [{ "text": text }],
    role": role
  });
}
```

こうすることにより会話の継続性が保たれます。

### 要約処理の導入とその課題：
ここで問題になってくるのが、会話が長く続いてくると当然履歴が長くなり、その分APIのコストが高くなったり、最悪トークンの制限にしてしまうこと。
そこである程度会話が往復したらそれまでのやり取りを裏で要約する様にしました。
クイズの内容が要約されてしまうと、AI自体がどんな問題を出しているのか忘れてしまうため、直前にクイズが出題されていた場合は必ず残すようにというプロンプトを強調して設定しています。
``` javascript
// 会話履歴が要約のしきい値を超えたかチェックし、要約をGeminiに依頼する関数
function summarizeConversationIfNecessary(userId, currentHistory) {
  
  if (currentHistory.length > SUMMARY_THRESHOLD) { 
    //SUMMARY_THRESHOLD: しきい値 今回は20（10往復分）を設定
    // 要約用のプロンプトを作成
    const summaryPrompt = `これまでの会話の要点を、その後の会話の文脈が分かるように簡潔に要約してください。要約は短くしてください。
      **直近でクイズを出題した場合は、その出題内容がわかるように必ず残してください。**`;
      
    // Geminiに要約を依頼するためのpayloadを作成
    const payloadForSummary = currentHistory.concat([
        { "parts": [{ "text": summaryPrompt }], "role": "user" } // 要約指示をユーザーとして追加
    ]);
  
    try {
      const summaryText = callGeminiAPI(payloadForSummary, userId, GEMINI_API_KEY);
      // 新しい履歴として要約だけを保存し、その後の会話のためのスペースを空ける
  
      const newHistory = [{ "parts": [{ "text": summaryText }], "role": "model" }]; // 要約をモデルの発言として保存
        
      return newHistory; // 新しい履歴を返す
  
    } catch (e) {
      Logger.log("Failed to summarize conversation: " + e.message);
      // 要約に失敗した場合は、既存の履歴をそのまま返すか、古い部分を削除するなどして対応
      // 一旦、ここでは元の履歴をそのまま返すことにする
      return currentHistory;
    }
  }
  return currentHistory; // しきい値を超えていなければそのままの履歴を返す
}
```
こうすることにより、会話の内容は保たれつつコストの削減やトークンの制限への対応がある程度可能となりました

### 「要約によるパーソナリティの喪失」という課題の発見と解決策：
しかしここで更に課題として浮かんできたのは、ティンクル先生のパーソナリティをプロンプトに含んでしまうと要約した際にそのパーソナリティが喪失してしまうということ。
対応策として常にプロンプトの中に含まれるようにするか？とも考えましたが、今回は `systemInstruction` としてGeminiに送ることにしました。
`systemInstruction`は、会話履歴の一部としてではなく、モデルの挙動全体を制御する高レベルな指示として機能するため、会話履歴が要約されてもその指示が消えることなく、**永続的にAIの行動に影響を与え続ける**というGemini APIの強力な機能です。

``` javascript 
  function callGeminiAPI(conversationHistory, userId, geminiApiKey) {
  
    const geminiUrl = `${GEMINI_API_ENDPOINT}${geminiApiKey}`;
  
    const headers = {
      'Content-Type': 'application/json'
    };
  
    const userName = PropertiesService.getUserProperties().getProperty(userId + "_name") || "生徒"; // ユーザー名を取得

    const payload = JSON.stringify({
      "systemInstruction": createSystemInstruction(userName), //ティンクル先生のパーソナリティ設定用プロンプトを渡す
      "contents": conversationHistory, // ここで履歴全体を渡す
      "generationConfig": generationConfig
    });
    
    const options = {
      'method': 'post',
      'contentType': 'application/json',
      'payload': payload
    };
  
    try {
      const response = UrlFetchApp.fetch(geminiUrl, options);
      const responseText = response.getContentText();
      const jsonResponse = JSON.parse(responseText);

      if (jsonResponse.candidates && jsonResponse.candidates.length > 0 && jsonResponse.candidates[0].content && jsonResponse.candidates[0].content.parts && jsonResponse.candidates[0].content.parts.length > 0) {
        return jsonResponse.candidates[0].content.parts[0].text;
      } else if (jsonResponse.error) {
        throw new Error(`Gemini API Error: ${jsonResponse.error.message}`);
      } else {
        throw new Error("Gemini APIから有効な返答がありませんでした。");
      }
  
    } catch(e) {
      Logger.log("Gemini API Error: " + e.message);
      throw new Error("Gemini API Error: " + e.message); // エラーを投げるように変更（doPostでcatchするため）
    }
  }
```
これで要約を行ってもティンクル先生のパーソナリティが喪失せずにやり取りを継続する事が可能となりました。
こちらの方が断然スマートですね。

## その他機能
### 画像解析機能：
ユーザーが質問を投げる際に、テキストメッセージだけでなく、画像を送ってからそれに対して何をして欲しいのかを送れる様にしてあります。
LINEから送られてきた画像を一時的にGoogleドライブに保存し、その後送られてきたユーザーからのメッセージと共にGeminiに渡すという処理になっています。
``` javascript 
function callGeminiAPIForImage(conversationHistory, userId, base64Image, imageMimeType, userMessage, geminiApiKey) {

  const geminiUrl = `${GEMINI_API_ENDPOINT}${geminiApiKey}`;
  const headers = {
    'Content-Type': 'application/json'
  };

  // 画像とテキストを組み合わせたコンテンツを作成（URL形式）
  const currentMultiModalContent = {
    "parts": [
      {
        "inlineData": {
          "mimeType": imageMimeType, // 取得したMIMEタイプを使用
          "data": base64Image,
        }
      },
      {
        "text": userMessage
      }
    ],
    "role": "user"
  };

  // 履歴と現在のマルチモーダルコンテンツを結合してペイロードを作成
  const userName = PropertiesService.getUserProperties().getProperty(userId + "_name") || "生徒"; // ユーザー名を取得

  const payload = JSON.stringify({
    "systemInstruction": createSystemInstruction(userName),
    "contents":  conversationHistory.concat([currentMultiModalContent]),
    "generationConfig": generationConfig
  });

  const options = {
    'method': 'post',
    'headers': headers,
    'payload': payload,
    'muteHttpExceptions': true
  };

  try {
    const response = UrlFetchApp.fetch(geminiUrl, options);
    const responseText = response.getContentText();
    const jsonResponse = JSON.parse(responseText);

    if (jsonResponse.candidates && jsonResponse.candidates.length > 0 && jsonResponse.candidates[0].content && jsonResponse.candidates[0].content.parts && jsonResponse.candidates[0].content.parts.length > 0) {
      return jsonResponse.candidates[0].content.parts[0].text;
    } else if (jsonResponse.error) {
      throw new Error(`Gemini API Error: ${jsonResponse.error.message}`);
    } else {
      throw new Error("Gemini APIから有効な返答がありませんでした。");
    }
  } catch (e) {
    Logger.log("Gemini API Call Error: " + e.message);
    throw new Error("Gemini API通信エラー: " + e.message);
  }
}
```
これにより、ユーザーは問題集や手書きのメモなどをカメラで撮影し、そこに書かれた内容について直接質問できるようになり、より直感的な学習体験を提供します。

### リフレッシュ／リセット機能：
数学の質問をした後に英語の質問をする、という事が普通にあると想定し、AIの回答がそれまでの会話履歴に引っ張られておかしな事にならない様にリフレッシュ／リセット機能も実装しました。
`#リフレッシュ`で`userName`以外の`PropertiesService`に保持されているデータを消去、
`#リセット`で`userName`を含めた全てのデータを消去するようにしています。
教科の切替時等にリフレッシュ機能を利用すれば、**AIが以前の会話の文脈に引きずられることなく、新しい教科やトピックに集中して適切な回答を生成できる**ようになり、回答品質の大幅な向上が期待できます。
## 実際にティンクル先生に質問してみた
実際にティンクル先生に質問をしてみました。
- 社会（歴史の問題）
![スクリーンショット 2025-06-20 183821.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2871252/a25dbd4a-b297-40c6-bdaa-da6ceb19217e.png)
この様に、すぐに答えである「徳川家康」と返すのではなく、自分で教科書や資料を調べて答えを見つけられる様にヒントを返してくれます。
- 数学（連立方程式の問題）
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2871252/7c91de8b-9a65-4393-87a3-fd88fd8f94a4.png)
 通過算と呼ばれるこの問題も、まずはどう考えれば良いかや何を求めるべきかを一緒に考えてくれます。

他の教科でも同様に考え方や調べ方のヒントを返してくれます。

## 最後に
今回実装の多くの部分をAIに作ってもらいました。
特にティンクル先生のパーソナリティに関わるプロンプトの部分はAIが生成したものをほぼそのまま使用しています。
自分がAIに頼りきりで「自分で考える力」がかなり衰退している事への反省も込めてこの機能を実装しました。
プロンプトの匙加減ひとつでAIから返ってくる回答が結構違ってくるのはやはり面白いですね。
今後は更にドキュメントファイル等を読み込んだりできるように機能拡張していければなと思っています。

まずは、私の身近な子供たちがこのティンクル先生を通じて学習の楽しさを再発見し、自律的な学びの第一歩を踏み出すきっかけとなれば、これほど嬉しいことはありません。