外部APIからHTTP 200が返ったことだけを見て、AIが作った返信案を次の処理へ渡すのは危険です。200でも、JSONが壊れている、別の案件のIDが入っている、本文が空白だけ、といった応答は返ってきます。
この記事では、Google Apps Script(GAS)で返信案APIの応答を検証し、契約に合うものだけを確認キューへ渡す最小実装を作ります。Qiita向けに、結果コード、シートへの記録、架空データのテストまでを実装寄りにまとめます。実際のAPI接続、顧客データの保存、顧客への自動送信は扱いません。
作るもの
次の流れを作ります。
- APIのHTTP応答を受け取る
- HTTP 200以外を理由コード付きで保留する
- 200のJSONについて、キー、案件ID、本文を検査する
- 合格した返信案だけを確認キューへ記録する
- 送信判断は別の人間確認へ残す
この例では、APIの成功契約を次のように仮定します。
- APIステータスは
200 - JSONのキーは
requestIdとdraftTextの2つだけ -
requestIdは受付台帳から渡した期待値と完全一致 -
draftTextは空白だけではない文字列 -
draftTextの上限は2,000 UTF-16コード単位
この数値やキーは架空の契約です。接続先の仕様へ置き換える前に、実際のレスポンス仕様とエラー時の扱いを確認してください。
判定結果を先に固定する
HTTPコードと本文の検査結果を、後続処理が分岐しやすいオブジェクトにします。REVIEW は「人間確認の候補にできる」という意味で、送信許可ではありません。
| 条件 | state | reason | draftText |
|---|---|---|---|
| 200かつ契約に合う | REVIEW |
DRAFT_RECEIVED |
原文 |
| 202 | HOLD |
PENDING |
null |
| 204 | HOLD |
NO_BODY |
null |
| 401・403 | HOLD |
AUTH_HTTP |
null |
| 429・503 | HOLD |
RETRY_REVIEW |
null |
| JSON・ID・本文が不正 | HOLD |
個別の理由 | null |
特に、保留時にAPIレスポンス本文をそのまま結果へ残さない点が重要です。エラー画面や別案件の本文が、確認キューやログへ流れないようにします。
GASでHTTP応答を検証する
外部通信を行わない純粋な判定関数です。GASのV8ランタイムで動かせます。
function inspectDraftResponse_(httpCode, responseBody, expectedRequestId) {
const hold = (reason) => ({
state: 'HOLD',
reason,
draftText: null,
});
if (!Number.isInteger(httpCode) || httpCode < 100 || httpCode > 599) {
return hold('HTTP_CODE_INVALID');
}
if (httpCode !== 200) {
if (httpCode === 202) return hold('PENDING');
if (httpCode === 204) return hold('NO_BODY');
if (httpCode === 401 || httpCode === 403) return hold('AUTH_HTTP');
if (httpCode === 429 || httpCode === 503) return hold('RETRY_REVIEW');
return hold('HTTP_UNEXPECTED');
}
if (typeof expectedRequestId !== 'string' ||
!/^REQ-[0-9]{3}$/.test(expectedRequestId)) {
return hold('EXPECTED_ID_INVALID');
}
if (typeof responseBody !== 'string' || responseBody.length > 20000) {
return hold('BODY_INVALID');
}
let payload;
try {
payload = JSON.parse(responseBody);
} catch (error) {
return hold('JSON_INVALID');
}
if (payload === null || typeof payload !== 'object' ||
Array.isArray(payload)) {
return hold('SHAPE_INVALID');
}
const keys = Object.keys(payload).sort().join(',');
if (keys !== 'draftText,requestId') {
return hold('KEYS_INVALID');
}
if (payload.requestId !== expectedRequestId) {
return hold('REQUEST_ID_MISMATCH');
}
if (typeof payload.draftText !== 'string' ||
payload.draftText.trim() === '' ||
payload.draftText.length > 2000) {
return hold('DRAFT_INVALID');
}
return {
state: 'REVIEW',
reason: 'DRAFT_RECEIVED',
draftText: payload.draftText,
};
}
trim() は空白だけかどうかの判定に使い、返却する本文には適用していません。返信案の先頭・末尾にある改行やスペースを、検証関数が勝手に変更しないためです。
また、JSON.parse の前に本文の上限を検査しています。これは通信量やダウンロード中のメモリ使用量を制限する機能ではありません。大きなレスポンスへの対策が必要なら、API側や取得処理側にも別の制限を置きます。
HTTPResponseから判定関数へ渡す
UrlFetchApp を接続する箇所は小さく保ち、判定ロジックと分離します。muteHttpExceptions: true を使う場合も、認証エラーを無視する意味にはなりません。HTTPコードを読み、保留理由を残します。
function fetchDraftForReview_(url, expectedRequestId) {
try {
const response = UrlFetchApp.fetch(url, {
method: 'get',
muteHttpExceptions: true,
headers: {
Accept: 'application/json',
},
});
const code = response.getResponseCode();
// 200以外の本文は読まず、ログやキューへコピーしない。
if (code !== 200) {
return inspectDraftResponse_(code, '', expectedRequestId);
}
return inspectDraftResponse_(
code,
response.getContentText('UTF-8'),
expectedRequestId,
);
} catch (error) {
return {
state: 'HOLD',
reason: 'RESPONSE_READ_FAILED',
draftText: null,
};
}
}
接続先URLや認証情報をAIの出力から決めないでください。URL、認証、期待する案件IDは、許可した設定や受付台帳から渡します。コード例では説明のためにURLを引数にしていますが、本番では接続先の許可リストも別途管理します。
確認キューへは本文を必要な範囲で記録する
確認担当者が見るキューには、判定状態と案件IDを記録します。ここでは例として、DRAFT_REVIEW_QUEUE シートの1行目に次の列を置きます。
received_at,request_id,state,reason,draft_text,review_status
次の関数は、判定済みの結果を1行追加します。APPROVED はこの関数では設定しません。
function appendDraftReviewQueue_(sheet, requestId, result) {
if (result.state === 'REVIEW') {
sheet.appendRow([
new Date(),
requestId,
result.state,
result.reason,
result.draftText,
'READY_FOR_REVIEW',
]);
return;
}
sheet.appendRow([
new Date(),
requestId,
result.state,
result.reason,
'',
'HOLD',
]);
}
保留時は本文を空欄にします。HTTPエラー本文、HTML、別案件の返信案を「後で見るため」としてキューへ複製しない設計です。保留理由から接続状態や案件IDの再確認を行い、必要なら担当者が元の安全な場所で調査します。
実運用で同じ案件を再処理する場合は、requestId だけでなく、取得試行IDやAPI側の重複防止キーも検討してください。429や503を見て、作成リクエストを無条件に再送する実装にはしません。相手側で処理が進んでいる可能性があるためです。
架空のレスポンスでテストする
外部APIを呼ばずに、判定関数の契約をテストできます。最初に正常系と、現場で混ざりやすい保留系を固定します。
function testInspectDraftResponse_() {
const id = 'REQ-001';
const good = JSON.stringify({
requestId: id,
draftText: ' 確認します。\\n',
});
const cases = [
[200, good, 'REVIEW', 'DRAFT_RECEIVED'],
[202, good, 'HOLD', 'PENDING'],
[204, '', 'HOLD', 'NO_BODY'],
[401, '<html>login</html>', 'HOLD', 'AUTH_HTTP'],
[429, good, 'HOLD', 'RETRY_REVIEW'],
[200, '<html>notice</html>', 'HOLD', 'JSON_INVALID'],
[200, JSON.stringify({ requestId: 'REQ-002', draftText: 'x' }),
'HOLD', 'REQUEST_ID_MISMATCH'],
[200, JSON.stringify({ requestId: id, draftText: ' ' }),
'HOLD', 'DRAFT_INVALID'],
];
cases.forEach(([code, body, state, reason], index) => {
const actual = inspectDraftResponse_(code, body, id);
if (actual.state !== state || actual.reason !== reason) {
throw new Error('case ' + (index + 1) + ' failed');
}
});
return 'PASS: ' + cases.length;
}
境界値として、次も追加してください。
-
draftTextが2,000単位と2,001単位 - 絵文字や結合文字を含む本文
- JSONの余分なキー、配列、
null -
requestIdの前後に空白がある場合 - 本文が空文字、数値、真偽値の場合
- HTTPコードが文字列の
'200'や小数の場合
JavaScriptの length はUTF-16コード単位です。画面で見える文字数、UTF-8のバイト数、AIのトークン数とは一致しないため、上限の意味を運用資料へ明記します。
人間確認へ戻す境界
検査に合格しても、返信を送信してよいとは限りません。確認担当者には少なくとも次を渡します。
- 案件IDと受付台帳の一致
- 宛先と返信対象の一致
- 返信の根拠となるFAQ・契約・社内資料
- 料金、納期、返金、苦情、個人情報を含む場合の担当者確認
- 送信後に誤りが分かった場合の停止・訂正ルート
AIへ渡す情報は、利用を許可した必要最小限にします。顧客本文や認証情報をテストデータへ入れず、まず架空データで判定を確認してください。REVIEW から APPROVED への変更は、人間の確認操作と記録によってのみ行います。
まとめ
HTTP 200は「通信の応答を受け取った」ことの一つの条件であり、「正しい返信案が完成した」「送信してよい」という意味ではありません。
- HTTPコードとJSON契約を分けて検査する
- 案件IDを期待値と完全一致させる
- 保留時はレスポンス本文を後段へ渡さない
- 受信、確認、送信の状態を分離する
- 架空の境界ケースをテストしてから接続する
Miraigentでは、AIに返信案を作らせる前に、どの情報を渡してよいか、どこで人へ戻すか、確認結果をどこへ残すかを業務ごとに整理します。
