問い合わせ返信を生成AIで下書きすると、確認者が毎回全文を読み直すことになります。文章が自然でも、金額、期限、URL、保証表現が承認済み文面から変わっていれば、その変更を送信前に止めなければなりません。
この記事ではNode.js 20以降とTypeScriptを使い、次の最小構成を作ります。
- 承認済み返信を比較元(baseline)として固定する
- AI下書きとの差分を行単位で抽出する
- 金額、期限、約束、URLを含む変更へ理由コードを付ける
- 根拠がない重要変更を送信不可にする
- 通常、要確認、停止のテストを自動化する
AI APIの呼び出し自体は扱いません。下書きを取得した後の確認工程だけを、外部サービスへ本文を再送せず実装します。
作るもの
承認済み返信 ─┐
├─ 差分抽出 ─ 重要変更の判定 ─ 人間確認キュー
AI下書き ─────┘ ├─ 承認 → 送信処理へ
根拠ID ────────────────────────────────└─ 不足 → 停止
Qiita向けの本文図解は、Miraigentの承認済み画像テンプレートで作成し、https://miraigent.com/assets/qiita-zenn/ 配下へ配置できた時点でこの位置に追加します。画像未配置でも実装とテストは再現できます。
セットアップ
mkdir ai-reply-diff-review
cd ai-reply-diff-review
npm init -y
npm install diff
npm install -D typescript tsx vitest @types/node @types/diff
package.json の主要部分です。
{
"type": "module",
"scripts": {
"test": "vitest run",
"check": "tsc --noEmit && vitest run"
}
}
src/
build-review-diff.ts
decide-review-route.ts
test/
review-diff.test.ts
差分へ理由コードを付ける
src/build-review-diff.ts を作ります。
import { diffLines } from "diff";
export type ReasonCode = "amount" | "deadline" | "commitment" | "link";
export type ReviewDiff = {
kind: "added" | "removed" | "unchanged";
text: string;
reasonCodes: ReasonCode[];
requiresReview: boolean;
};
const rules: Array<{ code: ReasonCode; pattern: RegExp }> = [
{ code: "amount", pattern: /(?:¥|¥|円|税込|税別|\d{1,3}(?:,\d{3})+)/u },
{ code: "deadline", pattern: /(?:まで|以内|営業日|期限|\d{4}-\d{2}-\d{2})/u },
{ code: "commitment", pattern: /(?:必ず|保証|確約|返金|無料)/u },
{ code: "link", pattern: /https?:\/\/\S+/u },
];
export function buildReviewDiff(
baselineText: string,
draftText: string,
): ReviewDiff[] {
return diffLines(baselineText, draftText).map((part) => {
const kind = part.added ? "added" : part.removed ? "removed" : "unchanged";
const reasonCodes = rules
.filter(({ pattern }) => pattern.test(part.value))
.map(({ code }) => code);
return {
kind,
text: part.value,
reasonCodes,
requiresReview: kind !== "unchanged" && reasonCodes.length > 0,
};
});
}
比較元には、前回のAI出力ではなく人が承認した版を使います。未確認のAI出力を次の比較元にすると、変更が少しずつ正本へ混ざるためです。
根拠の有無を含めて経路を決める
次に、差分の結果を ready、review、blocked へ分けます。
import type { ReasonCode, ReviewDiff } from "./build-review-diff.js";
type ReviewInput = {
diff: ReviewDiff[];
sourceIds: string[];
};
type RouteDecision = {
status: "ready" | "review" | "blocked";
reasonCodes: ReasonCode[];
canSend: false;
};
const sourceRequired = new Set<ReasonCode>([
"amount",
"deadline",
"commitment",
]);
export function decideReviewRoute(input: ReviewInput): RouteDecision {
const reasonCodes = [...new Set(
input.diff.flatMap((item) => item.requiresReview ? item.reasonCodes : []),
)];
const needsSource = reasonCodes.some((code) => sourceRequired.has(code));
if (needsSource && input.sourceIds.length === 0) {
return { status: "blocked", reasonCodes, canSend: false };
}
if (reasonCodes.length > 0) {
return { status: "review", reasonCodes, canSend: false };
}
return { status: "ready", reasonCodes: [], canSend: false };
}
ここでは、ready でも canSend は false のままです。ready は機械検査を通過したという意味であり、人間が宛先、本文、添付を承認したという意味ではありません。送信処理は別の承認記録を確認してから実行します。
3つのケースをテストする
test/review-diff.test.ts を作ります。
import { describe, expect, it } from "vitest";
import { buildReviewDiff } from "../src/build-review-diff.js";
import { decideReviewRoute } from "../src/decide-review-route.js";
describe("AI reply diff review", () => {
it("重要条件が変わらなければreadyにする", () => {
const diff = buildReviewDiff(
"お問い合わせありがとうございます。\n担当者が確認します。",
"お問い合わせありがとうございます。\n担当者よりご連絡します。",
);
expect(decideReviewRoute({ diff, sourceIds: [] }).status).toBe("ready");
});
it("根拠付きの期限変更はreviewにする", () => {
const diff = buildReviewDiff(
"3営業日以内に回答します。",
"2営業日以内に回答します。",
);
const result = decideReviewRoute({ diff, sourceIds: ["policy:v4"] });
expect(result.status).toBe("review");
expect(result.reasonCodes).toContain("deadline");
});
it("根拠なしの金額変更はblockedにする", () => {
const diff = buildReviewDiff(
"料金は10,000円です。",
"料金は8,000円です。",
);
const result = decideReviewRoute({ diff, sourceIds: [] });
expect(result.status).toBe("blocked");
expect(result.reasonCodes).toContain("amount");
expect(result.canSend).toBe(false);
});
});
npm run check
正規表現は誤検知も見逃しもあります。そのため「重要変更ではない」と自動確定するためではなく、人へ見せる優先順位付けに使います。実際の返信例から誤検知を記録し、ルールを版管理してください。
確認キューに保存する項目
本文だけでなく、判断を再現できる情報を保存します。
type ReviewRecord = {
inquiryId: string;
baselineId: string;
draftId: string;
sourceIds: string[];
promptVersion: string;
reasonCodes: string[];
status: "awaiting_review" | "approved" | "changes_requested" | "stopped";
reviewerRole?: string;
decisionReason?: string;
createdAt: string;
reviewedAt?: string;
};
個人名をログへ過剰に残さず、まず役割と判断を記録します。担当者IDが必要な場合は、社内規程、アクセス権、保持期間を決めてから追加します。問い合わせ本文、認証情報、秘密情報を外部AIへ送る前には、利用サービスの保存・学習利用・契約条件も確認してください。
実運用チェックリスト
-
比較元を承認済みの
baselineIdで固定した - AI下書きと根拠ID、プロンプト版を同時に保存する
- 金額、期限、約束、URLの変更を理由コードへ変換する
-
根拠がない重要変更を
blockedにする -
readyと「送信承認済み」を別の状態にした - 承認、差し戻し、停止の操作を分けた
- 宛先と添付を送信直前に人が確認する
- 通常、境界、停止ケースをテストした
- 誤検知と見逃しを記録し、ルールを版管理する
- 個人情報とログの保持期間を決めた
まとめ
AI返信の確認負荷を減らす最初の一歩は、全文確認を省略することではありません。承認済みの版を固定し、重要な変更箇所と根拠を人が判断できる単位へ変えることです。
まず実際の承認済み返信1件とAI下書き1件で差分を出し、「変わったら送信を止めたい項目」を3つ決めてください。Miraigentでは、このようなAI導入前の確認単位、正本、承認責任、停止条件を無料診断の対象として整理しています。