0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GASで返信案APIの応答を検証する:HTTP 200でも送信せず確認キューへ戻す

0
Posted at

外部APIからHTTP 200が返ったことだけを見て、AIが作った返信案を次の処理へ渡すのは危険です。200でも、JSONが壊れている、別の案件のIDが入っている、本文が空白だけ、といった応答は返ってきます。

この記事では、Google Apps Script(GAS)で返信案APIの応答を検証し、契約に合うものだけを確認キューへ渡す最小実装を作ります。Qiita向けに、結果コード、シートへの記録、架空データのテストまでを実装寄りにまとめます。実際のAPI接続、顧客データの保存、顧客への自動送信は扱いません。

作るもの

返信案APIの応答を人間確認へ渡すゲート

次の流れを作ります。

  1. APIのHTTP応答を受け取る
  2. HTTP 200以外を理由コード付きで保留する
  3. 200のJSONについて、キー、案件ID、本文を検査する
  4. 合格した返信案だけを確認キューへ記録する
  5. 送信判断は別の人間確認へ残す

この例では、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に返信案を作らせる前に、どの情報を渡してよいか、どこで人へ戻すか、確認結果をどこへ残すかを業務ごとに整理します。

参照

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?