👮

97.5%の精度を実現!Gmailで動く反社チェックAIを1日で作ってみた

に公開

こんにちは!Finatextのクレジット事業で、エンジニアをしている名澤です。

突然ですが、皆さんは子供の時、夏休みの課題は計画的に進められる方でしたか?
僕は最終日にまとめてやろうとして、結局終わらず先生に怒られるタイプでした。

今回社内のAIコンテストが開催され、「チームクレジット」として、賞金総額100万円に目が眩みエントリーしたのはいいものの、気づいた時には提出期限まで残り1日になっていました...

「やばい!」と思い、自分が業務で関わる中で「作業に時間がかかる」「やり方が曖昧」な業務を思い返し、1日という制約の中でAIの力を最大限活用してプロダクトを作ることに挑戦しました。

AI反社チェックGmailアドオン

アドオンのスクリーンショット

メールの送信元や本文から企業名や氏名といった要素を取り出して、ウェブ検索と反社DB(暴追センターなどから提供されるデータ)に対して検索を行い、「反社と関わりがあるか」や「信用度が低くないか」などのネガティブ情報をベースに0~100のスコアと、その理由を提示するアドオンです。

APIも提供しているので、他のメールクライアントや、お問い合わせフォームへの埋め込みなど、どこからでも呼び出せるようになっています。

提供価値

  • 業務効率の大幅向上: 手作業による確認・検索業務を完全自動化し時間とコストを大幅に削減
  • 判断精度の向上: LLM活用による多角的な情報分析と、人的ミス・見落としリスクの低減
    • LLMの解析は従来の反社DBへの照会や検索エンジンでのリサーチに比べてかなり柔軟なため、例えば反社ではないが風評があまりよくないみたいな会社など、潜在的なリスクも含めてリサーチがしやすくなる。
  • 拡張性: APIとしても提供することで、各種プロダクトに容易に統合可能

ベンチマーク

カテゴリ 平均スコア テスト数 精度
DB上で反社該当あり 89.5 20 100%
LLM上で反社該当あり 86.0 20 95%
Web検索上で疑わしい情報あり(反社該当ではない) 21.8 20 95%
反社該当なし 0.0 20 100%

詳細分析

高リスク判定(DB・LLM)

  • DB検出: 平均スコア 89.5点 - 反社データベースに該当する企業は確実に高リスクと判定
  • LLM検出: 平均スコア 86.0点 - Web検索とLLMの知識を組み合わせた判定で高精度を実現

中リスク判定(疑わしい情報)

  • Web検索検出: 平均スコア 21.8点 - 法令違反や不祥事はあるが反社該当ではない企業を適切に中リスクで分類

低リスク判定(問題なし)

  • クリーン: 平均スコア 0.0点 - 問題のない企業・個人は確実に低リスクと判定

スコア分布

スコア範囲 判定レベル 該当数
0-19 低リスク 20
20-49 中リスク 20
50-79 高リスク 0
80-100 最高リスク 40

検出精度

全体精度: 97.5% - 80件中78件で正確な判定を実現
DB検出: 100% - データベース照合による検出は完璧
⚠️ LLM検出: 95% - Web検索+LLM判定で1件誤判定
⚠️ 中リスク判定: 95% - 疑わしい情報の分類で1件誤判定

技術スタック

今回は特にスピード重視で作りつつ、一定の保守性も担保できるような仕組みにするために、できるだけ慣れている言語・環境でかつ、AIエージェント開発と相性が良く、実現したい機能のライブラリが十分に揃っているという3つの条件をもとに技術選定を行いました。

バックエンド

  • 言語: Typescript
  • 実行環境: Bun
  • Webフレームワーク: Hono
  • バリデーション: Zod
  • DB: PostgreSQL(w/Drizzle ORM)
  • LLM API: Azure OpenAI
    • GPT-4.1: データのパース、検索APIの呼び出しなど
    • o4-mini: スコアリングとエビデンスの提示

API開発において便利なミドルウェアが豊富にあるHonoを選択することで、CORS対応、バリデーション、OpenAPI仕様書の作成などをほとんどゼロコストで行えるようになりました。

TSで書いたスキーマ
const checkRequestSchema = z
  .object({
    from: z.string().optional(),
    cc: z.array(z.string()).optional(),
    subject: z.string(),
    body: z.string().min(1, 'body is required'),
  })
  .openapi({
    description:
      'メールの件名と本文を元に、反社DBとWeb検索を行い、リスク評価を行います。',
  })

const checkResponseSchema = z.object({
  code: z.number().openapi({
    example: 200,
  }),
  message: z.string().openapi({
    example: 'Success',
  }),
  result: scoringTotalResponseSchema.openapi({
    example: {
      score: 80,
      evidences: [
        {
          name: '織田信長',
          description:
            '織田信長は過去に指定暴力団体 ロケット団と関連がある可能性があります。',
        },
        {
          name: 'ロケット団',
          description: 'ロケット団は指定暴力団体に指定されています。',
        },
      ],
    },
  }),
  details: z.array(
    z.object({
      name: z.string().openapi({
        example: '織田信長',
      }),
      entityType: antisocialEntityType.optional().openapi({
        example: 'individual',
      }),
      dbMatches: z.array(
        z.object({
          id: z.string().uuid(),
          name: z.string(),
          entityType: antisocialEntityType.optional(),
          aliases: z.array(z.string()).optional(),
          association: z.string().optional(),
          details: z.string().optional(),
          sourceInfo: z.string().optional(),
        }),
      ),
      webSearchResult: z.object({
        name: z.string().openapi({
          example: 'ポケモンセンター',
        }),
        suspected: z.boolean().openapi({
          example: false,
        }),
        entityType: antisocialEntityType.optional().openapi({
          example: 'individual',
        }),
        evidences: z.array(
          z.object({
            source: z.string().openapi({
              example: 'https://example.com',
            }),
            content: z.string().openapi({
              example: '特に問題となる情報は見つかりませんでした。',
            }),
          }),
        ),
      }),
      metadata: z.array(antisocialExtractItemMetadataSchema).optional(),
    }),
  ),
})

export const antisocialCheckEmailRoute = createRoute({
  method: 'post',
  summary: 'root route',
  description: 'check',
  path: '/antisocial/check/email',
  request: {
    body: {
      content: {
        'application/json': {
          schema: checkRequestSchema,
        },
      },
      required: true,
    },
  },
  responses: {
    200: {
      content: {
        'application/json': {
          schema: checkResponseSchema,
        },
      },
      description: '',
    },
    400: {
      content: {
        'application/json': {
          schema: z.object({
            code: z.number(),
            message: z.string(),
          }),
        },
      },
      description: 'Bad Request',
    },
    500: {
      content: {
        'application/json': {
          schema: z.object({
            code: z.number(),
            message: z.string(),
          }),
        },
      },
      description: 'Bad Request',
    },
  },
})

出力されたOpenAPI仕様

また、DrizzleとZodを組み合わせることで、型定義を相互に連携することができるので、LLMから帰ってきた情報など、ソフトウェア側で扱う情報の型を元にDBのスキーマ定義が行えるようになっているので、DB <=> サーバー間でのデータのパース用のコードが不要になっている点もスピード向上の要因です。

export const entityTypeEnumItems = [
  'individual',
  'company',
  'other_organization',
] as const

export const entityTypeEnum = pgEnum('entity_type_enum', entityTypeEnumItems)

export const antisocialEntities = pgTable(
  'antisocial_entities',
  {
    id: uuid('id').defaultRandom().primaryKey(),

    // 種別 (必須)
    entityType: entityTypeEnum('entity_type').notNull(), // Enumを使用
  },
  ...
},

フロント(Gmailアドオン)

  • 言語: Typescript
  • 実行環境: Google Apps Script(GAS)

GmailのアドオンはGASで実装することになるのですが、正直あまり開発体験が良くないのと、UIカスタムの自由度が低い点から、普段であればできれば避けたいプラットフォームなのですが、今回ここをVibe Codingすることで、大幅に工数を削減しました。

UIカスタムの自由度が低い点は一点デメリットに見えますが、逆に言えば、表示ズレやデザインの統一感が失われるなどの、AIエージェントにフロントエンドを開発させる上での懸念点をクリアできているということでもあるので、実はGmailアドオンとAIエージェント開発はかなり相性がいい(出力のブレが少ない) と思っています。

実際フロントの開発にかかった時間は30分程度でかつ、環境構築時以外でほぼ直接コードをいじることなく開発することができました。

設計/実装におけるポイント

LLMプロセスの分割

今回以下の3つのプロセスに分割しています。

1. メールからの情報抽出: GPT-4.1

  • メールのタイトルやアドレス、本文から人物・企業名と、それらに関連しそうなメールアドレスや電話番号、所属などの情報を取得し連携
  • それぞれの関連性をJSONで出力

2.スクリーニング: GPT-4.1

  • Web検索とLLMの知識から、反社に該当しそうな情報が含まれていないかをチェックして返す

3.評価: o4-mini

  • LLMによるスクリーニングやDBから得られた情報を元に、反社該当しそうなエンティティとそれらに対するエビデンスを提示
  • 評価軸を元に、渡された情報全体に対して、0-100のスコアをつける

LLMプロセスを分割するメリット

  • コンテキストサイズの縮小化
    • メール自体が長い場合に、検索含むスクリーニングと評価までさせると、コンテキストサイズが大きくなる
    • コンテキストサイズが大きくなることにより、ハルシネーションや指示に対しての精度が低くなりやすい
  • エラーハンドリング
    • 全ての処理を一度に行うと、LLM側の処理が落ちた場合に1からリトライが必要になる
  • より柔軟なフローへの対応
    • スクリーニングで情報が見つからない場合に評価プロセスをスキップする
    • メールの添付ファイルも分析に加える
    • メール以外のリソース(電話内容など)をベースに評価する

反社DBの実装

今回LLMとWeb検索以外にも反社情報を記録したDBを用意しました。暴追センターなどから定期的に共有されるリストなどを追加することで、より信頼できるデータソースとして扱うことができます。

これにより、データ精度の向上と、スコアの重みづけ(DBに含まれている場合は必ずスコアを70以上にする)が可能になりました。

AIを活用した開発フロー

今回実質的に1日しか開発に時間を取れないということで、できるだけAIにアウトソーシングすることで開発効率を上げました。

アイデア出し

  • クレジットドメインの中で扱っている機能の中で、AIによって業務効率の改善が見込めそうなアイデアをいくつか出してもらった(例: 収入証明書OCR, 電話対応ログ生成)

技術選定/設計

  • フロント・バックエンドで同じ言語(Typescript)を使うという前提でAI開発と相性の良いフレームワークやORMなどを選定してもらった
  • API仕様とDBスキーマはCline + Claude 3.7 Sonnetで生成。今回はHono OpenAPIや、Drizzleを使っていたため、そのまま実装として扱える

バックエンド実装

  • API仕様、DBスキーマと、OpenAIの仕様をClineに読ませて、型やLLMとやりとりするadapterなどを実装。そこまでの処理とファイル構成をmarkdownで書き出して、再度Clineにそのファイルを渡して、機能ごとにステップバイステップで実行させることで、微修正以外ほぼ全てAIがコーディング

LLMプロンプト

  • Clineで生成したベースのプロンプトをGemini 2.5 Proに読ませてブラッシュアップしてベンチマークというプロセスを繰り返すことで、精度を向上させた。直接人間がプロンプトを書き換えたりはしていない

フロントエンド実装

  • 環境構築はGeminiに聞きながら
    • Typescriptで開発する方法や動作確認方法など
  • 実装はAPI仕様を元に完全Vibeコーディング

結果

惜しくも最優秀賞は取れませんでしたが、優秀賞をいただきました🙌

結果発表〜!!!

おわりに

今回の開発を通じて改めて感じたのは、AIを活用した開発における「適切な分業」の重要性です。アイデア出しから技術選定、実装、プロンプト改善まで、人間が判断すべき部分とAIに任せるべき部分を明確に分けることで、短時間でも実用レベルのプロダクトを作ることができました。

特に印象的だったのは、Cline + Claude Sonnetによるバックエンド実装の自動化と、GASのVibeコーディングです。従来なら数日かかる作業を数時間で完了でき、LLMのみの知識でも、97.5%という高精度を実現できたのは、AIツールの進化を肌で感じる体験でした。

一方で、LLMプロセスの分割設計や反社DBとの連携など、ドメイン知識が必要な部分は依然として人間の設計力が重要で、AIと人間の役割分担の最適解を見つけることが今後のAI開発の鍵になりそうだなと思いました。

最後まで読んでいただきありがとうございました!

Finatext Tech Blog

Discussion