AIに問い合わせの要約や返信案を作らせると、JSONで受け取る設計は扱いやすく見えます。しかし、JSON.parse()が成功しただけでは、業務データとして受け入れてよいとは限りません。
配列が返る、必須キーが欠ける、予定外の approved が混ざる、呼び出し元とは別の案件IDが返る、といった入力は、形式を直さず確認待ちへ戻す必要があります。
この記事では、Google Apps Script(GAS)で次の検査を行う小さな関数を実装します。
- JSONの構文、ルート型、必須キー、値の型を検査する
- 呼び出し元が持つ受付IDと、AI出力の案件IDを照合する
- 形式に合格しても
REVIEW_REQUIREDで止める - 不合格の本文を後続処理へ渡さない
AI API、顧客への自動送信、承認の自動確定は実装しません。実データの代わりに架空の受付IDを使います。
1. 受け入れるJSONの契約を決める
今回の返信案は、次の3キーだけを持つオブジェクトとします。
{
"caseId": "INQ-001",
"summary": "営業時間について確認したい",
"replyDraft": "現行FAQを確認してから回答します。"
}
| キー | 検査する条件 | この検査で分からないこと |
|---|---|---|
caseId |
文字列で、呼び出し元の受付IDと完全一致 | 閲覧権限や受付IDの正しさ |
summary |
空欄でない文字列、300コードポイント以下 | 要約が原文の条件を保っているか |
replyDraft |
空欄でない文字列、1000コードポイント以下 | 料金・納期・宛先・根拠の正しさ |
キーを増やしたい時は、許可キー、確認画面、テストを一緒に変更します。approved や canSend のようなキーを、検査だけで送信許可へ変換しないことが重要です。
なお、300と1000はこの記事のサンプル上限です。サービスや組織の正式な文字数制限を意味しません。Array.from(text).length はコードポイント数であり、バイト数やAIのトークン数とは異なります。
2. GASに純粋な検査関数を置く
新しいテスト用のApps Scriptプロジェクトを作り、V8ランタイムで次の関数を実行します。スプレッドシートや外部サービスには接続しません。
function inspectAiReplyJson(raw, expectedCaseId) {
if (typeof expectedCaseId !== 'string' ||
expectedCaseId.trim() === '' || expectedCaseId.length > 64) {
throw new Error('EXPECTED_CASE_ID_INVALID');
}
if (typeof raw !== 'string') return { status: 'HOLD_RAW_TYPE' };
// JSON.parse前の入力上限。UTF-16コード単位で数える。
if (raw.length > 8000) return { status: 'HOLD_RAW_LENGTH' };
let data;
try {
data = JSON.parse(raw);
} catch (error) {
return { status: 'HOLD_JSON_SYNTAX' };
}
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
return { status: 'HOLD_ROOT_TYPE' };
}
const requiredKeys = ['caseId', 'summary', 'replyDraft'];
const hasOnlyRequiredKeys =
Object.keys(data).length === requiredKeys.length &&
requiredKeys.every((key) =>
Object.prototype.hasOwnProperty.call(data, key)
);
if (!hasOnlyRequiredKeys) return { status: 'HOLD_KEYS' };
if (requiredKeys.some((key) => typeof data[key] !== 'string')) {
return { status: 'HOLD_FIELD_TYPE' };
}
if (data.caseId !== expectedCaseId) return { status: 'HOLD_CASE_ID' };
if (data.summary.trim() === '' || data.replyDraft.trim() === '') {
return { status: 'HOLD_EMPTY_TEXT' };
}
if (Array.from(data.summary).length > 300 ||
Array.from(data.replyDraft).length > 1000) {
return { status: 'HOLD_TEXT_LENGTH' };
}
// 形式に合格しても、承認・送信可能にはしない。
return {
status: 'REVIEW_REQUIRED',
draft: {
caseId: data.caseId,
summary: data.summary,
replyDraft: data.replyDraft
}
};
}
expectedCaseId は呼び出し側が信頼できる受付台帳から取得します。AIの返答から取り出したIDを照合基準にしてはいけません。IDの不一致は、AIに新しいIDを発行させるのではなく、元の受付と出力の紐付けを確認する状態にします。
説明文付きのJSONを正規表現で修復したり、数値を String() で補ったりもしません。形式契約と異なる値は、その理由を残して確認キューへ戻します。
3. 固定ケースで境界を確認する
同じプロジェクトに次のテストを追加します。すべて架空の値で、外部サービスを呼びません。
function assertStatus_(name, raw, expected) {
const result = inspectAiReplyJson(raw, 'INQ-001');
if (result.status !== expected) {
throw new Error(name + ': expected ' + expected +
', got ' + result.status);
}
if (expected.indexOf('HOLD_') === 0 && 'draft' in result) {
throw new Error(name + ': held result must not include draft');
}
}
function testInspectAiReplyJson() {
const good = {
caseId: 'INQ-001',
summary: '営業時間の確認',
replyDraft: '現行FAQを確認してから回答します。'
};
const raw = JSON.stringify(good);
const changed = (patch) => JSON.stringify(Object.assign({}, good, patch));
assertStatus_('正常', raw, 'REVIEW_REQUIRED');
assertStatus_('不正JSON', '{', 'HOLD_JSON_SYNTAX');
assertStatus_('説明文付き', '結果: ' + raw, 'HOLD_JSON_SYNTAX');
assertStatus_('null', 'null', 'HOLD_ROOT_TYPE');
assertStatus_('配列', '[]', 'HOLD_ROOT_TYPE');
assertStatus_('承認キー追加', changed({ approved: true }), 'HOLD_KEYS');
assertStatus_('キー欠落', JSON.stringify({
caseId: 'INQ-001', summary: '例'
}), 'HOLD_KEYS');
assertStatus_('IDの数値化', changed({ caseId: 1 }), 'HOLD_FIELD_TYPE');
assertStatus_('別案件', changed({ caseId: 'INQ-002' }), 'HOLD_CASE_ID');
assertStatus_('空欄', changed({ replyDraft: ' \n ' }), 'HOLD_EMPTY_TEXT');
assertStatus_('要約301', changed({ summary: 'a'.repeat(301) }),
'HOLD_TEXT_LENGTH');
assertStatus_('要約300', changed({ summary: 'a'.repeat(300) }),
'REVIEW_REQUIRED');
console.log('PASS: inspectAiReplyJson boundary cases');
}
正常ケースの戻り値も REVIEW_REQUIRED です。これは形式検査を通っただけで、内容、根拠、宛先、送信可否を人が確認する必要があるためです。
4. 確認キューへ保存する最小項目
検査結果をSheetsへ記録する場合、返信本文を一般ログへコピーせず、まず次のような管理項目だけを保存します。
review_id,case_id,received_at,status,reason,reviewer,next_review_at,source_ref
| status | 意味 | 次の処理 |
|---|---|---|
REVIEW_REQUIRED |
形式は合格、人が内容を確認する | 根拠・条件・宛先を確認 |
HOLD_* |
形式または紐付けに問題がある | 理由に応じて入力元を確認 |
source_ref は権限管理された原本への参照です。個人情報、認証情報、顧客本文を集計用のログへ複製しない構成にします。保留理由ごとに担当者と再確認期限を決め、同じ不正出力を無制限に再生成しないようにします。
5. この検査の範囲を明記する
この関数が確認できるのは、構文、形、型、空欄、文字数、案件IDの一致です。次は保証しません。
- 返信案の事実、FAQの版、料金・納期、契約条件の正しさ
- 案件を閲覧・処理してよい権限
- 本文に混ざった指示や、プロンプトインジェクションの有無
- 同名キーが元のJSON文字列に複数あったかどうか
- HTMLや数式のような文字列を安全に表示・保存できること
確認画面では出力をテキストとして表示し、外部データをそのままHTMLとして解釈しないでください。個人情報や未公開の契約情報をAIへ入力してよいかも、形式検査とは別に判断します。
導入前チェックリスト
- 受付IDをAI出力ではなく、信頼できる受付台帳から渡した
- 必須キー、型、空欄、長さ、案件不一致を固定ケースで検査した
- 余分なキーを黙って削除・補完していない
-
合格後も
REVIEW_REQUIREDで止め、自動承認・自動送信へ直結していない - 保留理由、担当者、再確認期限、正本への参照を残している
- 本文や秘密情報を一般ログへ出していない
- 形式検査と、根拠・権限・宛先・内容の確認を分けている
JSONの形を検査すると、AIの出力をそのまま業務処理へ流さずに済みます。ただし、合格は「人が確認できる材料がそろった」という意味に限定します。Miraigentでは、AIに任せる処理を増やす前に、形式上の境界と人間確認の戻し先を台帳へ固定します。
