この記事は、AIエージェントやAI返信下書きを導入する前に、誰が、何を、なぜ判断したかをGoogleスプレッドシートとGASで残すための実装メモです。
AI APIの呼び出しは扱いません。先に、判断待ち、承認、保留、差し戻し、例外対応を記録できる軽い台帳を作ります。
作るもの
Googleフォームや問い合わせログに対して、次のような判断ログを作ります。
- decision_id: 判断ID
- source_id: 元の問い合わせやタスクID
- decision_type: 判断種別
- status: 判断状態
- requested_by: 判断を依頼した人
- owner: 判断する人
- reason: 判断が必要な理由
- decision: 決定内容
- next_action: 次にやること
- due_at: 期限
- decided_at: 決定日時
完成形は、AI導入前の小さなDecision Logです。
AIに任せる範囲を増やす前に、人間が迷った判断を残します。
なぜ判断ログが必要なのか
AI導入では、プロンプトや自動化フローを先に作りたくなります。
ただ、現場で止まるのは、文章生成そのものではありません。
- これはAIに任せてよいのか
- 誰が承認すればよいのか
- なぜ保留になったのか
- 前回はどう判断したのか
- 似たケースで、判断が変わっていないか
- 例外対応をFAQや運用ルールに反映したか
これらが残っていないと、AIエージェントを入れても毎回同じ確認が発生します。
判断ログは、AIのためというより、現場の迷いを減らすための記録です。
シート構成
最初は1枚のシートで十分です。
| 列 | 項目 | 例 | 用途 |
|---|---|---|---|
| A | created_at | 2026/06/03 11:15 | 判断依頼の作成日時 |
| B | decision_id | DEC-20260603-001 | 判断を一意に追うID |
| C | source_id | INQ-20260603-014 | 元の問い合わせやタスクID |
| D | source_type | inquiry | 元データの種別 |
| E | decision_type | ai_transfer | 判断種別 |
| F | status | pending | 判断状態 |
| G | requested_by | 担当者A | 判断を依頼した人 |
| H | owner | 責任者B | 判断する人 |
| I | reason | 契約条件を含むため | 判断が必要な理由 |
| J | options | approve / hold / reject | 選択肢 |
| K | decision | hold | 決定内容 |
| L | next_action | 顧客へ追加確認 | 次にやること |
| M | due_at | 2026/06/03 15:00 | 期限 |
| N | decided_at | 2026/06/03 12:20 | 決定日時 |
| O | note | 料金表の更新も必要 | 補足 |
ポイントは、判断結果だけでなく、判断が必要になった理由も残すことです。
理由が残ると、後でAIに渡すルール、FAQ、承認フローを改善できます。
decision_typeの例
最初は細かくしすぎず、5種類ほどで始めます。
| decision_type | 使う場面 |
|---|---|
| ai_transfer | AIに情報を渡してよいか |
| reply_approval | 顧客返信を送ってよいか |
| exception_handling | 例外ケースとして人間が見るか |
| faq_update | FAQやテンプレートへ反映するか |
| crm_stage_change | CRM上の状態を進めるか |
この分類は、AIエージェントのタスク分類にも使えます。
statusの例
判断ログでは、状態を固定します。
| status | 意味 |
|---|---|
| pending | 判断待ち |
| approved | 承認済み |
| rejected | 却下 |
| hold | 保留 |
| returned | 差し戻し |
| done | 次アクション完了 |
「確認中」「対応中」「たぶんOK」のような曖昧な状態を増やすと、AIにも人間にも扱いにくくなります。
GASコード
Apps Scriptを開き、次のコードを貼ります。
const CONFIG = {
headerRow: 1,
sheetName: 'DecisionLog',
columns: {
createdAt: 1,
decisionId: 2,
sourceId: 3,
sourceType: 4,
decisionType: 5,
status: 6,
requestedBy: 7,
owner: 8,
reason: 9,
options: 10,
decision: 11,
nextAction: 12,
dueAt: 13,
decidedAt: 14,
note: 15
}
};
const DECISION_RULES = [
{
type: 'ai_transfer',
keywords: ['個人情報', '電話番号', '住所', '返金', '解約', '契約', 'クレーム'],
owner: '責任者',
reason: 'AIへ渡す前に人間確認が必要',
options: 'approve / mask / reject / hold'
},
{
type: 'reply_approval',
keywords: ['送信してよい', '返信案', 'お詫び', '料金', '納期'],
owner: '担当責任者',
reason: '顧客返信前の承認が必要',
options: 'approve / returned / hold'
},
{
type: 'faq_update',
keywords: ['よくある質問', 'FAQ', '同じ質問', 'テンプレート'],
owner: 'CS担当',
reason: 'FAQ反映可否の判断が必要',
options: 'approve / reject / hold'
}
];
function createDecisionFromInquiry(source) {
const sheet = getDecisionSheet_();
const rule = findDecisionRule_(source.body || '');
const now = new Date();
const decisionId = buildDecisionId_(sheet, now);
const dueAt = new Date(now.getTime() + 4 * 60 * 60 * 1000);
sheet.appendRow([
now,
decisionId,
source.id || '',
source.type || 'inquiry',
rule.type,
'pending',
source.requestedBy || '未設定',
rule.owner,
rule.reason,
rule.options,
'',
'判断後に次アクションを記入',
dueAt,
'',
source.body || ''
]);
return decisionId;
}
function findDecisionRule_(text) {
const body = String(text || '');
const matched = DECISION_RULES.find((rule) =>
rule.keywords.some((keyword) => body.includes(keyword))
);
return matched || {
type: 'exception_handling',
owner: '担当者',
reason: '分類ルールに未登録のため確認',
options: 'approve / hold / returned'
};
}
function updateDecision(decisionId, decision, nextAction, note) {
const sheet = getDecisionSheet_();
const values = sheet.getDataRange().getValues();
const idCol = CONFIG.columns.decisionId - 1;
for (let i = CONFIG.headerRow; i < values.length; i += 1) {
if (values[i][idCol] === decisionId) {
const status = normalizeDecisionStatus_(decision);
const row = i + 1;
sheet.getRange(row, CONFIG.columns.status).setValue(status);
sheet.getRange(row, CONFIG.columns.decision).setValue(decision);
sheet.getRange(row, CONFIG.columns.nextAction).setValue(nextAction || '');
sheet.getRange(row, CONFIG.columns.decidedAt).setValue(new Date());
sheet.getRange(row, CONFIG.columns.note).setValue(note || '');
return { decisionId, status };
}
}
throw new Error('Decision not found: ' + decisionId);
}
function normalizeDecisionStatus_(decision) {
const value = String(decision || '').toLowerCase();
if (['approve', 'approved'].includes(value)) return 'approved';
if (['reject', 'rejected'].includes(value)) return 'rejected';
if (['returned', 'return'].includes(value)) return 'returned';
if (['hold', 'pending'].includes(value)) return 'hold';
return 'pending';
}
function getPendingDecisions() {
const sheet = getDecisionSheet_();
const values = sheet.getDataRange().getValues();
const statusCol = CONFIG.columns.status - 1;
return values
.slice(CONFIG.headerRow)
.filter((row) => ['pending', 'hold', 'returned'].includes(row[statusCol]))
.map((row) => ({
decisionId: row[CONFIG.columns.decisionId - 1],
sourceId: row[CONFIG.columns.sourceId - 1],
type: row[CONFIG.columns.decisionType - 1],
status: row[CONFIG.columns.status - 1],
owner: row[CONFIG.columns.owner - 1],
reason: row[CONFIG.columns.reason - 1],
dueAt: row[CONFIG.columns.dueAt - 1]
}));
}
function buildDecisionId_(sheet, date) {
const yyyymmdd = Utilities.formatDate(date, 'Asia/Tokyo', 'yyyyMMdd');
const count = Math.max(0, sheet.getLastRow() - CONFIG.headerRow) + 1;
return 'DEC-' + yyyymmdd + '-' + String(count).padStart(3, '0');
}
function getDecisionSheet_() {
const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
const sheet = spreadsheet.getSheetByName(CONFIG.sheetName) || spreadsheet.insertSheet(CONFIG.sheetName);
ensureHeader_(sheet);
return sheet;
}
function ensureHeader_(sheet) {
const headers = [
'created_at',
'decision_id',
'source_id',
'source_type',
'decision_type',
'status',
'requested_by',
'owner',
'reason',
'options',
'decision',
'next_action',
'due_at',
'decided_at',
'note'
];
const current = sheet.getRange(CONFIG.headerRow, 1, 1, headers.length).getValues()[0];
if (current.join('') !== headers.join('')) {
sheet.getRange(CONFIG.headerRow, 1, 1, headers.length).setValues([headers]);
}
}
function demoCreateDecision() {
return createDecisionFromInquiry({
id: 'INQ-20260603-001',
type: 'inquiry',
requestedBy: 'CS担当',
body: '解約と返金について相談がありました。AI返信に進めてよいか確認したいです。'
});
}
使い方
- スプレッドシートに DecisionLog シートを作ります。
- Apps Scriptに上記コードを貼ります。
- demoCreateDecision() を実行します。
- DEC-YYYYMMDD-001 の形式で判断ログが作られることを確認します。
- updateDecision('DEC-20260603-001', 'hold', '契約条件を確認する', '料金表と過去契約を確認') のように更新します。
実運用では、問い合わせ受付シートの行から createDecisionFromInquiry() を呼び出します。
AI導入時に使う判断材料
判断ログを残すと、AIエージェント導入時に次の情報が使えます。
- どの判断が多いか
- どの判断が保留になりやすいか
- どの担当者に確認が集中しているか
- どのルールが曖昧か
- FAQやテンプレートに反映すべきケースは何か
AIに「判断してください」と任せる前に、人間の判断がどこで詰まっているかを見ます。
運用チェックリスト
- 判断IDが必ず残る
- 判断理由が空欄にならない
- 承認者と依頼者が分かる
- 保留、差し戻し、却下を正常な状態として扱う
- 判断後の次アクションが残る
- AIへ渡す前の判断と、顧客へ送信する前の判断を混ぜない
- 未分類ケースをルール改善候補として残す
まとめ
AIエージェント導入前に必要なのは、最初から高度な自動化を作ることではありません。
まず、人間が迷った判断を残します。
判断ログがあると、AIに任せる範囲、人間が見る範囲、FAQ化する範囲が見えます。
AI導入は、判断を消すためではなく、判断すべき場所を見えるようにしてから進める方が安定します。
Miraigentでは、AI導入前の業務整理として、問い合わせログ、FAQ候補、CRM状態、判断ログを先に整えることを重視しています。
Miraigentの公開リソース
Miraigentでは、AI導入前に整えるべき問い合わせ対応、FAQ、CRM、人間確認ルールを整理しています。
