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?

AI返信案のJSONをGASで検証する:必須キー・型・案件IDを確認キューへ戻す

0
Last updated at Posted at 2026-10-13

AIに問い合わせの要約や返信案を作らせると、JSONで受け取る設計は扱いやすく見えます。しかし、JSON.parse()が成功しただけでは、業務データとして受け入れてよいとは限りません。

配列が返る、必須キーが欠ける、予定外の approved が混ざる、呼び出し元とは別の案件IDが返る、といった入力は、形式を直さず確認待ちへ戻す必要があります。

この記事では、Google Apps Script(GAS)で次の検査を行う小さな関数を実装します。

  • JSONの構文、ルート型、必須キー、値の型を検査する
  • 呼び出し元が持つ受付IDと、AI出力の案件IDを照合する
  • 形式に合格しても REVIEW_REQUIRED で止める
  • 不合格の本文を後続処理へ渡さない

AI API、顧客への自動送信、承認の自動確定は実装しません。実データの代わりに架空の受付IDを使います。

AI返信案の形式を検査して人間確認へ戻す流れ

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に任せる処理を増やす前に、形式上の境界と人間確認の戻し先を台帳へ固定します。

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?