ChatGPTで営業メールを要約 → DBに保存 → クエリで探せる仕組みを作ってみた

# 営業メールって、正直つらくないですか？
毎日のように届く営業メール、
・数が多い
・検索がしづらい
・欲しい情報が載っていないことも多い

そんな不便さを何とかしたくて、IT業界のエンジニア向け営業メールをAIで字句解析して、DBに保存して検索できるアプリを作ってみました。

## 表示イメージ
このように集計できるようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3753262/6c9d3953-4038-47c5-8182-8ac9e25ffb0c.png)


## 設計
### ミッション
営業メールを構造化し、検索可能な形で保存する。
### 分析対象
対象は「案件メール」と「人材メール」の2種類。  
※すべてのメールを解析するとAPIコストがかかるため、Gmailのフィルター機能でラベルが付与されたメールのみを対象とします。
### 設計上のポイント
- 表現の揺らぎはAIによって吸収  
- メール本文と構造化データは分離して保存  
- 言語・フレームワークなどの技術キーワードは、マスタ管理により柔軟な検索を実現
## サービス選定
### メール：Gmail
社内で利用しているため、連携もスムーズ。
### AI：ChatGPT
ローカルLLMも試しましたが精度がイマイチだったため、API型を選定。
その中で、精度と価格のバランスから GPT-4.1 miniを採用しました。

### 解析フォーマット
営業メールは主に「案件紹介」と「人材紹介」の2種類に分けられます。
それぞれ定型的な情報が含まれていますが、表現に揺らぎがあるため抽出はAIに任せます。
<details><summary>フォーマット例</summary>

```json
[
    {
        "メール区分": "案件 or 人材",
        "案件名": "Go/AWS なんとか業界の開発案件",
        "業務": [
            "バックエンド実装",
            "インフラ構築"
        ],
        "入場時期・開始時期": [
            "2025/06/01",
            "2025/07/01"
        ],
        "終了時期": "~長期",
        "勤務場所": "東京都",
        "単価FROM": 800000,
        "単価TO": 900000,
        "言語": [
            "TypeScript",
            "JavaScript",
            "PHP"
        ],
        "フレームワーク": [
            "React",
            "Laravel"
        ],
        "ポジション": [
            "PL",
            "PM",
            "SE",
            "PG"
        ],
        "求めるスキル MUST": [],
        "求めるスキル WANT": [],
        "リモートワーク区分": "フルリモート or リモート可 or 不可",
        "リモートワークの頻度": "週一回"
    }
]
```
</details>

# 処理の流れ
1. CLI からコマンドを実行して処理を開始
2. Gmail API で特定ラベルがついているメールを取得
※ラベルへのメール振り分け設定が完了している想定です
3. ChatGPT API に投げて要約・構造化
4. JSONで受け取り、案件 or 人材に分類
5. パースしてDBに保存（メール本文は別途保存）
※（将来的に）フロントで検索インターフェース提供予定


## DB設計
検索性を高めるため、emails テーブルに基本情報を格納し、案件／人材情報を子テーブルとして管理する。
言語やフレームワークなどは表現に揺れがあるため、単語を正規化するマスタ設計が必要。
例えば「PHP(Laravel)」のような単語は、言語とフレームワークの両カテゴリに対応できる構造とする。
以上を踏まえるとこのような設計になりました。
※人材情報は今後対応予定です。


<details><summary>フォーマット例</summary>

```yml
tables:
  emails:
    role: "全メール共通の基本情報（件名・送信元・本文など）"
    relation: []

  email_projects:
    role: "案件メール専用の詳細情報（単価・勤務地・技術要素など）"
    relation:
      - emails (1:1)
      - entry_timings (1:N)
    note: "一覧画面用に技術・業務・ポジションなどをカンマ区切り文字列でも保持（二重管理）"

  entry_timings:
    role: "案件の入場時期（複数）を正規化管理"
    relation: ["email_projects (N:1)"]

  keyword_groups:
    role: "正規化された技術キーワードのマスタ（PHP、Reactなど）"
    relation: ["key_words (1:N)", "email_projects (N:N keyword_group_word_links)"]
  
  keyword_group_word_links:
    role: "email_projects と keyword_groups を多対多で結びつける中間テーブル"
    relation: ["email_projects (N:1)", "keyword_groups (N:1)"]

  key_words:
    role: "キーワードの表記ゆれを keyword_groups に紐付ける"
    relation: ["keyword_group_word_links (N:1)"]

  key_words:
    role: "キーワードの表記ゆれを keyword_groups に紐付ける"
    relation: ["keyword_groups (N:1)"]

  email_keyword_groups:
    role: "emails と keyword_groups の多対多中間テーブル（type区分あり）"
    relation: ["emails (N:1)", "keyword_groups (N:1)"]

  position_groups:
    role: "正規化されたポジション名のマスタ（例: PM, PL）"
    relation: ["position_words (1:N)", "email_position_groups (1:N)"]

  position_words:
    role: "ポジションの表記ゆれを position_groups に紐付ける"
    relation: ["position_groups (N:1)"]

  email_position_groups:
    role: "emails と position_groups の多対多中間テーブル"
    relation: ["emails (N:1)", "position_groups (N:1)"]

  work_type_groups:
    role: "正規化された業務種別マスタ（例: バックエンド開発）"
    relation: ["work_type_words (1:N)", "email_work_type_groups (1:N)"]

  work_type_words:
    role: "業務表記ゆれを work_type_groups に紐付ける"
    relation: ["work_type_groups (N:1)"]

  email_work_type_groups:
    role: "emails と work_type_groups の多対多中間テーブル"
    relation: ["emails (N:1)", "work_type_groups (N:1)"]
```
</details>

## 言語選定  
ぱぱっと開発したかったのと、ローカル環境の構築が自分の既存リポジトリから流用できたこと、そして学習目的もあって、Go言語を採用しました。  
あとから振り返ると、Laravelでフロントも一緒に作ったほうがよかったかも…と少し後悔してます（笑）

## 今後の対応について
1. 人材情報メールへの対応
2. フロントの作成
 ・単語マスターの管理画面
 ・検索インターフェース
   ※気になる、既読、非表示などでより選別を簡単にすることも可能
 ・案件・人材情報に単語を追加で紐づける
 
## ソースコード
※フロントは時間に余裕ができたら作る予定です。

https://github.com/shrimptails-f/gmail_parser