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?

Node.jsでAI返信下書きの差分レビューを作る:金額・期限・URLの変更を送信前に止める

0
Posted at

問い合わせ返信を生成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出力を次の比較元にすると、変更が少しずつ正本へ混ざるためです。

根拠の有無を含めて経路を決める

次に、差分の結果を readyreviewblocked へ分けます。

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 でも canSendfalse のままです。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導入前の確認単位、正本、承認責任、停止条件を無料診断の対象として整理しています。

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?