はじめに
初めまして、實藤陸(さねふじりく)です。
2025年新卒入社で、現在は製造エネルギーサービス事業部に所属しています。
今回は新卒入社の私がプロジェクト効率化ツールとして、GAS(文字起こし自動追加GAS)の開発をAIと共同で行いましたので、その内容について共有できればと思います!
💡 本記事のメイントピック
本記事では、開発したツールの概要だけでなく、IT未経験の新卒が、どうやってAI(Gemini)に指示を出し、実運用レベルのシステムを作り上げたかというAIとの共同開発プロセスに重きを置いて学びを共有します。
開発の背景と課題
今回の開発に至った経緯は、PJにおいて下記のような背景と課題があったためです。
| 背景 |
Meet会議で生成されるGeminiの文字起こしや録画ファイルを、PJ共有ドライブに手動で蓄積し、振り返りに活用していた。 |
| 課題 |
ファイルは主催者のマイドライブ内 Meet Recordings フォルダに自動保存される仕様。そのため、共有ドライブへの移動の手間や移動漏れが頻発し、そのたびにSlackで依頼するコミュニケーションコストが発生していた。 |
そこで、この一連の作業を自動化するGASの開発に着手しました。
文字起こし自動追加GASの概要
まずは作成したツールの概要を紹介します。
文字起こし自動追加GASは簡潔に言うと、Google Meetの文字起こしや録画ファイルを、所定のフォルダに自動で整理し、Slackに通知するGASです!
2つのGASを用いて、Google Meet(のGemini機能)によって自動生成され、個人のマイドライブに保存される『文字起こし・録画・チャット』ファイルを、PJ共有ドライブ内の各フォルダへ移動し、Slackへ通知します。
GASのざっくり構成図
それぞれのGASの内容
- GAS(1):各自のマイドライブから「一時プール」へ(各自で実行)
- 各自のマイドライブから30分おきに自動で実行。
- ファイル名の先頭に
YYYYMMDD_ を追加し、特定キーワード(顧客名など)を含む会議だけを共有ドライブ内の「一時プールフォルダ」へ移動させます。
- GAS(2):プールから各フォルダへ仕分け&通知(管理者が実行)
- 毎日夜間に自動実行。
- プール内のファイルを「顧客会議用」「内部会議用」などに自動で振り分け、移動件数やエラー結果をSlackに通知します。
なぜSlack通知するのか?
処理のログを残し、正しい移動先へ格納されたか(あるいはエラーが起きていないか)を可視化して、メンバー全員が確認できるようにするためです。
これにより、どのメンバーが会議の主催者であっても、「文字起こし・録画・チャット」ファイルをPJの共有ドライブの正しいフォルダへ自動で蓄積できるようになり、当初の課題が解決できました!
AIとの共同開発のプロセス
今回の開発において大いに助けられたのが、Geminiの存在でした。
自分はIT未経験入社の新卒で、GASに関する知見はまったくない状態でした。そのため、最初から完璧な設計をするのではなく、とりあえずAIに作ってもらい、動くものを見ながらツッコミを入れて改善していくというアジャイル的な開発プロセスをとりました。
ここではコードを1行も書くことなく、GASを完成させたプロセスをご紹介します。
(1) まずは「動くGAS」を作る!
開発のスタートは、「どうすればいいんだ」というざっくりとした質問をGeminiに投げることでした。
当時の私は何をすればいいかよくわかっていなかったため、綿密な要件定義は行わず、以下のようなニュアンスで指示を出しました。
💬 Geminiへの指示のイメージ
「Google Meetの文字起こしファイルを、マイドライブから共有ドライブに集めたい。とりあえず進め方と動くGASのコードを考えて」
こんな抽象度の高いプロンプトでも、Geminiは大枠の方針を示し、最低限動くコードを一瞬で作成してくれました。
(2) 人間が「観点」を与え、AIにブラッシュアップさせる
動くものができたことで、実際に運用するには何が足りないかが明確に見えてきました。ここからは、上長とも相談しながら、Geminiが作った初期コードに対して人間ならではの観点を追加していく反復作業に入りました。
具体的にGeminiにぶつけた観点を2つ紹介します。
観点1:セキュリティと可視化
初期のコードでは、Slackへの通知機能がなく移動結果が確認しにくかったり、フォルダのID等がコードに「ベタ打ち」されていたりと、セキュリティ観点がほぼ抜け落ちていました。
そこで、コードをレビューさせたり、ポイントで以下のようにツッコミを入れました。
💬 Geminiへのツッコミのイメージ
「ここのフォルダIDやSlackのURL、コードに直接ベタ打ちで書いてるけどセキュリティ的に大丈夫?ベタ打ちを避ける方法で書き直して」
この指示により、外部から見えない「スクリプトプロパティ」を使ってIDを隠す、安全なシステムへと改善してくれました。
観点2:本番運用に向けたアーキテクチャの変更
当初の開発では、「1つのGASを各メンバーにデプロイしてもらい、各自のマイドライブから直接共有ドライブの各フォルダに振り分ける形式」となっていました。
しかし、この構成で本番展開しようとすると、SlackのWebhookやトリガー設定など各メンバーの初期設定の手間が大きすぎることが判明しました。
そこで上長と相談し、横展開のしやすさを最優先して「各自が投げるGASと、管理者が仕分けるGASの2つに分ける」という方針に変更しました。
💬 Geminiへの指示のイメージ
「このツールをチームに横展開することを考えて、各自が投げるGASと管理者が仕分けるGASの2つにコードを分割して」
このように、ざっくりとした要件でまず動くものを作り、必要な観点を後からぶつけていくというプロセスを繰り返すことで、無事に実運用できるツールを完成させることができました。
本開発における学び
ここからは今回のAIとの共同開発における学びを共有できればと思います。
Geminiってすごい
まずは、IT未経験で研修を受けた程度の自分が、今回の開発を業務と並行して短期間で完遂できたのは間違いなくGeminiのおかげです。
今回はGASということもあり、ほとんどエラーのない正確なコードを提供してくれました。
大感謝です。
アジャイルな進め方がAI時代のスタンダード?
今回のように未経験者がAIと協働開発をするには、最初から完璧な設計をするのではなく、とりあえずAIに作ってもらい、動くものを見ながらツッコミを入れて改善していくべきであると感じました(というかそれしか方法がない)。
さらに今後、AIとの共同開発が当たり前になる世界の中では、どんな難易度の開発であっても、コーディングにかかる時間が激減するため、こういったアジャイルな進め方が主流になっていくのではないかと思っています。
そう考えてみると、今回の進め方はまさにAIとの共同開発におけるスタンダードだったのではと個人的には感じています。
人間がやるべきこと
AIとの共同開発が当たり前になる世界のなかで、人間がやるべきこととして、主に下記2点が重要だと感じています。
- なぜその機能が必要なのかを考えること
Geminiは指示された通りのコードを作るのは得意な一方で、やはり背景を察したりすることはできません。
そのため、「この機能作って」というプロンプトではなく、「現状こんな課題があって、それを解決するためにこんな機能を作りたい」というプロンプトにする方が圧倒的に求めているものに近いアウトプットができます。
すなわち、実現したいことの背景や目的、ゴールを整理して示すことがAIとの共同開発においては重要だと学びました。
- コードを理解し観点を与えること
前述の通り、Geminiは最低限のコードは生成してくれますが、不足している観点があることも多いです(自分の知識不足も原因ではありますが)。
そこで人間に求められるのは、コードの内容を正しく理解しレビューをする力だと思っています。
実際にその機能を運用することを想定して、多くの観点からレビューをすることで、AIとの共同開発でも品質の高いアウトプットが出せることを学びました。
🎯 学びの総括
今後、AIの書くコードの正確性は高まっていく中で人間に求められる力としては、コードを理解する知識はもちろん、その機能を作る背景や目的・ゴールを正しく理解し、必要な観点を洗い出せる力なのではと感じています。
終わりに
今回は文字起こし自動追加GASの概要と、AIとの共同開発での学びについて共有させていただきました。
PJ効率化ツールとして文字起こし自動追加GASに興味を持っていただけていたら、非常にうれしいです!
また、自分と同じ若手メンバーなど、IT初学者の方にとってAIとの共同開発の進め方が何かの役に立てば幸いです。
最後まで読んでいただきありがとうございました!
おまけ:今回作成したGASのソースコード(マスキング済)
ご参考までに、実際にAIと作成して運用しているソースコード(※機密情報のみマスキング済)を掲載します。
GAS①:マイドライブから一時プールへ移動する処理(各自実行)
const CONFIG = { sourceFolderName: 'Meet Recordings',
poolFolderId: PropertiesService.getScriptProperties().getProperty('POOL_FOLDER_ID'),
targetKeywords: [ '^\\d{8}_【顧客名A】', '^\\d{8}_【顧客名B】', '^\\d{8}_【内部プロジェクトA】', '^\\d{8}_【内部プロジェクトB】' ],
targetMimeTypes: [MimeType.GOOGLE_DOCS, 'video/mp4', MimeType.PLAIN_TEXT] };
function moveToPoolWithFilter() { if (!CONFIG.poolFolderId) { console.error('❌ 設定エラー: POOL_FOLDER_ID が設定されていません。'); return; }
const props = PropertiesService.getScriptProperties(); const lastRunStr = props.getProperty('LAST_RUN_TIME'); const now = new Date();
let searchDateStr = '2000-01-01T00:00:00';
if (lastRunStr) { const lastDate = new Date(lastRunStr); if (!isNaN(lastDate.getTime())) { lastDate.setMinutes(lastDate.getMinutes() - 1); searchDateStr = Utilities.formatDate(lastDate, 'GMT', "yyyy-MM-dd'T'HH:mm:ss"); } }
const query = `modifiedDate > '${searchDateStr}'`; console.log(`🔎 高速検索クエリ: ${query}`);
const sourceFolders = DriveApp.getFoldersByName(CONFIG.sourceFolderName); const poolFolder = DriveApp.getFolderById(CONFIG.poolFolderId); const regexPatterns = CONFIG.targetKeywords.map(k => new RegExp(k));
let movedCount = 0;
while (sourceFolders.hasNext()) { const folder = sourceFolders.next();
try { const files = folder.searchFiles(query);
while (files.hasNext()) { const file = files.next();
if (file.isTrashed()) continue; if (!CONFIG.targetMimeTypes.includes(file.getMimeType())) continue;
try { const isMoved = processFile(file, poolFolder, regexPatterns); if (isMoved) { movedCount++; } } catch (e) { console.error(`❌ ファイル処理エラー: ${file.getName()} (${e.message})`); } } } catch (e) { console.error(`❌ 検索エラー(query: ${query}): ${e.message}`); } }
props.setProperty('LAST_RUN_TIME', now.toISOString());
if (movedCount > 0) { console.log(`✅ 処理完了: ${movedCount}件のファイルを転送しました。`); } else { console.log(`✅ 新規転送なし(${searchDateStr} 以降)`); } }
function processFile(file, poolFolder, regexPatterns) { const originalName = file.getName(); let datePrefix = '';
const dateMatch = originalName.match(/(\d{4})[\/\-\s](0[1-9]|1[0-2])[\/\-\s](0[1-9]|[12]\d|3[01])/);
if (dateMatch) { datePrefix = `${dateMatch[1]}${dateMatch[2]}${dateMatch[3]}`; } else { const createdDate = file.getDateCreated(); datePrefix = Utilities.formatDate(createdDate, 'Asia/Tokyo', 'yyyyMMdd'); }
let newName = originalName;
if (!originalName.startsWith(datePrefix)) { newName = `${datePrefix}_${originalName}`; }
const isMatch = regexPatterns.some(regex => regex.test(newName)); if (!isMatch) return false;
if (file.getName() !== newName) { file.setName(newName); }
if (poolFolder.getFilesByName(newName).hasNext()) { console.log(`⚠️ [スキップ] プールに同名ファイルが存在: ${newName}`); return false; }
file.moveTo(poolFolder); console.log(`⭕️ [転送成功] ${newName}`); return true; }
|
GAS②:プールから内部/外部フォルダへ仕分け&Slack通知(管理者実行)
const CONFIG = { poolFolderId: PropertiesService.getScriptProperties().getProperty('POOL_FOLDER_ID'), internalFolderId: PropertiesService.getScriptProperties().getProperty('INTERNAL_FOLDER_ID'), externalFolderId: PropertiesService.getScriptProperties().getProperty('EXTERNAL_FOLDER_ID'), slackWebhookUrl: PropertiesService.getScriptProperties().getProperty('SLACK_WEBHOOK_URL'),
rules: [ { name: '外部', propKey: 'EXTERNAL_FOLDER_ID', keywords: ['^\\d{8}_【顧客名A】','^\\d{8}_【顧客名B】'] }, { name: '内部', propKey: 'INTERNAL_FOLDER_ID', keywords: ['^\\d{8}_【内部プロジェクトA】','^\\d{8}_【内部プロジェクトB】'] } ] };
function runBatchSort() { Logger.log('--- 管理者バッチ処理開始 ---');
if (!CONFIG.poolFolderId || !CONFIG.internalFolderId || !CONFIG.externalFolderId || !CONFIG.slackWebhookUrl) { Logger.log('❌ エラー: スクリプトプロパティが不足しています。'); return; }
const slackLogs = []; const poolFolder = DriveApp.getFolderById(CONFIG.poolFolderId); const files = poolFolder.getFiles();
let moveCount = 0; let remainCount = 0; let errorCount = 0;
const rules = CONFIG.rules.map(r => ({ folder: DriveApp.getFolderById(PropertiesService.getScriptProperties().getProperty(r.propKey)), regex: r.keywords.map(k => new RegExp(k)), name: r.name }));
while (files.hasNext()) { const file = files.next(); const fileName = file.getName(); let isMoved = false;
try { for (const rule of rules) { if (rule.regex.some(r => r.test(fileName))) {
if (rule.folder.getFilesByName(fileName).hasNext()) { const msg = `⚠️ [重複] ${fileName} は ${rule.name} に既に存在するためスキップしました`; Logger.log(msg); slackLogs.push(msg); isMoved = true; break; }
file.moveTo(rule.folder);
const logMsg = `✅ [移動] ${fileName} ➡ ${rule.name}`; Logger.log(logMsg); slackLogs.push(logMsg);
moveCount++; isMoved = true; break; } }
if (!isMoved) { Logger.log(`⚠️ [残留] ${fileName} (条件不一致)`); remainCount++; }
} catch (e) { const errorMsg = `❌ [エラー] ${fileName}: ${e.message}`; Logger.log(errorMsg); slackLogs.push(errorMsg); errorCount++; } }
Logger.log(`処理終了: 移動${moveCount}件 / 残留${remainCount}件 / エラー${errorCount}件`);
if (slackLogs.length > 0) { sendDailyReport(slackLogs, moveCount, remainCount); } else { Logger.log('通知対象なしのためSlack送信をスキップします。'); } }
function sendDailyReport(logs, moveCount, remainCount) { const message = { "text": "📊 *議事録 自動仕分けレポート*", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": `📊 *本日の仕分け結果*\n実行完了: ${moveCount}件 / プール残留: ${remainCount}件` } }, { "type": "divider" }, { "type": "section", "text": { "type": "mrkdwn", "text": logs.join("\n") } } ] };
try { UrlFetchApp.fetch(CONFIG.slackWebhookUrl, { "method": "post", "contentType": "application/json", "payload": JSON.stringify(message) }); } catch (e) { Logger.log('Slack送信エラー: ' + e.message); } }
|