GASで「提出物チェック→AI文案作成」を半自動化する校務Tips
Googleフォームやスプレッドシートで提出状況を管理していると、未提出者の抽出と連絡文の作成が地味に時間を取ります。この記事では、Google Apps Script(GAS)で未提出者を拾い、Gemini APIで保護者・生徒向けの連絡文案を作る最小構成を紹介します。
この記事で作るもの
- Googleスプレッドシートの提出状況を読み取る
- 「未提出」の行だけを抽出する
- Gemini APIに文案生成を依頼する
- 結果を同じシートに書き戻す
- 時間主導型トリガーで定期実行できる形にする
実際の送信はしません。メール送信やチャット投稿まで自動化すると誤送信リスクが上がるため、この記事のコードは「文案を作ってシートに置く」までにしています。
動作前提
Google公式ドキュメントでは、Apps ScriptのRangeについて「Access and modify spreadsheet ranges」と説明されています。つまり、シート上の範囲を読み書きする用途には SpreadsheetApp / Range が使えます。
UrlFetchAppについては、公式ドキュメントに「communicate with external hosts」とあり、HTTP/HTTPSで外部APIへリクエストできます。また、UrlFetchAppの利用には https://www.googleapis.com/auth/script.external_request スコープが必要です。
Properties Serviceは、公式ドキュメントで「store strings as key-value pairs」と説明されています。APIキーのような設定値はコードに直書きせず、スクリプトプロパティから読む構成にします。
時間主導型トリガーは、公式ドキュメントで「similar to a cron job」と説明されています。毎朝などの定期実行に使えます。
Gemini APIの公式ドキュメントでは、Generate Content APIについて「content generation」と説明されています。今回は連絡文案の生成に使います。
参考:
- Google Apps Script Range: https://developers.google.com/apps-script/reference/spreadsheet/range
- Google Apps Script UrlFetchApp: https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app
- Google Apps Script Properties Service: https://developers.google.com/apps-script/reference/properties
- Google Apps Script Installable Triggers: https://developers.google.com/apps-script/guides/triggers/installable
- Gemini API Generate content: https://ai.google.dev/api/generate-content
シート構成
シート名は Submissions とします。
| A列 | B列 | C列 | D列 | E列 |
|---|---|---|---|---|
| student_name | class_name | assignment | status | ai_draft |
| 山田太郎 | 1-A | 数学プリント | 未提出 | |
| 佐藤花子 | 1-A | 数学プリント | 提出済 |
status が 未提出 の行だけを対象にし、ai_draft に文案を書き戻します。
APIキーの保存
APIキーはコードに書かず、Apps Scriptのスクリプトプロパティに保存します。
- Apps Scriptエディタで「プロジェクトの設定」を開く
- 「スクリプト プロパティ」に
GEMINI_API_KEYを追加する - 値にGemini APIキーを設定する
この構成にすると、本文のコードにはAPIキー文字列が残りません。
コード
const SHEET_NAME = 'Submissions';
const MODEL_NAME = 'gemini-2.0-flash';
const GEMINI_ENDPOINT =
`https://generativelanguage.googleapis.com/v1beta/models/${MODEL_NAME}:generateContent`;
function createMissingSubmissionDrafts() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
if (!sheet) {
throw new Error(`Sheet not found: ${SHEET_NAME}`);
}
const values = sheet.getDataRange().getValues();
if (values.length < 2) {
return;
}
const header = values[0];
const col = {
studentName: header.indexOf('student_name'),
className: header.indexOf('class_name'),
assignment: header.indexOf('assignment'),
status: header.indexOf('status'),
aiDraft: header.indexOf('ai_draft'),
};
validateColumns(col);
const apiKey = PropertiesService
.getScriptProperties()
.getProperty('GEMINI_API_KEY');
if (!apiKey) {
throw new Error('GEMINI_API_KEY is not set in Script Properties.');
}
const output = values.slice(1).map((row) => {
const currentDraft = row[col.aiDraft];
const status = row[col.status];
if (status !== '未提出' || currentDraft) {
return [currentDraft];
}
const prompt = buildPrompt({
studentName: row[col.studentName],
className: row[col.className],
assignment: row[col.assignment],
});
return [generateDraftWithGemini(prompt, apiKey)];
});
sheet
.getRange(2, col.aiDraft + 1, output.length, 1)
.setValues(output);
}
function validateColumns(col) {
const missing = Object.entries(col)
.filter(([, index]) => index === -1)
.map(([name]) => name);
if (missing.length > 0) {
throw new Error(`Missing columns: ${missing.join(', ')}`);
}
}
function buildPrompt({ studentName, className, assignment }) {
return [
'あなたは学校事務を支援する文章作成アシスタントです。',
'次の未提出連絡文を、丁寧で短く、責める表現を避けて作成してください。',
'事実として与えられた情報だけを使ってください。',
'',
`クラス: ${className}`,
`氏名: ${studentName}`,
`課題: ${assignment}`,
'',
'出力は本文のみ。件名、署名、箇条書きは不要です。',
].join('\n');
}
function generateDraftWithGemini(prompt, apiKey) {
const response = UrlFetchApp.fetch(GEMINI_ENDPOINT, {
method: 'post',
contentType: 'application/json',
headers: {
'x-goog-api-key': apiKey,
},
payload: JSON.stringify({
contents: [
{
parts: [
{
text: prompt,
},
],
},
],
generationConfig: {
temperature: 0.3,
},
}),
muteHttpExceptions: true,
});
const statusCode = response.getResponseCode();
const body = response.getContentText();
if (statusCode < 200 || statusCode >= 300) {
throw new Error(`Gemini API error: ${statusCode} ${body}`);
}
const json = JSON.parse(body);
const text = json.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) {
throw new Error(`Gemini API returned no text: ${body}`);
}
return text.trim();
}
定期実行トリガー
毎朝7時台に実行する例です。初回だけ手動実行します。
function createDailyTrigger() {
ScriptApp.newTrigger('createMissingSubmissionDrafts')
.timeBased()
.everyDays(1)
.atHour(7)
.create();
}
使うときの注意点
AIが作る文章は、送信前にシート上で確認できる状態にしておくと扱いやすくなります。特に校務では、氏名・課題名・提出状況の誤りがそのまま文章に反映されるため、送信処理まで一気につなげず、まずは文案生成までに区切るのが実務向きです。
また、未提出の判定文字列を 未提出 に固定しているため、フォーム側で 未提出 のような余分な空白が入ると対象外になります。運用に合わせて String(status).trim() !== '未提出' のように調整してください。
まとめ
GASのスプレッドシート読み書き、UrlFetchApp、Properties Service、時間主導型トリガーを組み合わせると、提出状況の確認と連絡文案作成を軽くできます。
ポイントは、AIに直接送信まで任せないことです。まずは「未提出者を抽出して、確認可能な文案をシートに残す」形にすると、校務フローへ段階的に入れやすくなります。