🤖

AIを活用した自動分類とバッチ処理基盤の構築

に公開

概要

メール管理システムにおいて、AIを活用してメール内容から重要な情報を自動抽出・分類する仕組みを構築しました。特に「30〜40歳」のような範囲表現を自動的にmin/max値に変換し、データベースで効率的に検索できるようにするバッチ処理基盤を実装しました。本記事では、OpenAI/Claude APIを使った情報抽出と、大量データを効率的に処理するバッチ処理の設計について解説します。

技術スタック

  • OpenAI GPT-4 / Claude 3: テキスト分類・情報抽出
  • TypeScript / Node.js: バックエンド実装
  • Prisma ORM: データベース操作
  • BullMQ: ジョブキュー管理
  • PostgreSQL: データストレージ

実装内容

1. AI分類サービスの実装

メール内容から構造化情報を抽出するAIサービスを実装:

// ai-classification-service.ts
interface EmailClassificationResult {
  classification: 'PROJECT' | 'PERSON' | 'OTHER';
  projectTitle?: string;
  skills?: string[];

  // 新たに追加したフィールド
  ageRange?: string;        // "30〜40歳", "20代後半"など
  salaryRange?: string;      // "月50〜70万", "年収600万以上"など
  commute?: string;          // "通勤月2回", "フルリモート"など
  bpRate?: string;           // "BP65%", "BP70%以上"など
  businessDepth?: string;    // "元請け", "2次請け"など
  requirements?: string[];   // その他の要件
}

class AIClassificationService {
  private openai: OpenAI;
  private anthropic: Anthropic;
  private costTracker = new CostTracker();

  async classifyEmail(
    emailContent: string,
    provider: 'openai' | 'claude' = 'openai'
  ): Promise<EmailClassificationResult> {
    // コスト試算
    const tokens = this.estimateTokens(emailContent);
    const estimatedCost = this.calculateCost(tokens, provider);

    // コスト上限チェック
    const MAX_COST_PER_EMAIL = 0.1; // 1メールあたり最大10円
    if (estimatedCost > MAX_COST_PER_EMAIL) {
      console.warn(
        `Email classification cost (¥${estimatedCost}) exceeds limit`
      );
      // フォールバック: ルールベース分類
      return this.fallbackClassification(emailContent);
    }
    const prompt = this.buildPrompt(emailContent);

    if (provider === 'openai') {
      return await this.classifyWithOpenAI(prompt);
    } else {
      return await this.classifyWithClaude(prompt);
    }
  }

  private buildPrompt(emailContent: string): string {
    return `
    以下のメール内容を分析し、JSON形式で情報を抽出してください。

    【抽出項目】
    1. classification: メールの種類(PROJECT/PERSON/OTHER)
    2. projectTitle: 案件名
    3. skills: 必要スキル(配列)
    4. ageRange: 年齢範囲(例: "30〜40歳", "20代後半")
    5. salaryRange: 給与範囲(例: "月50〜70万", "年収600万円以上")
    6. commute: 通勤条件(例: "通勤月2回", "フルリモート")
    7. bpRate: BP率(例: "BP65%", "BP率70%以上")
    8. businessDepth: 商流(例: "元請け", "2次請け")
    9. requirements: その他の要件(配列)

    【メール内容】
    ${emailContent}

    【出力形式】
    必ずJSON形式で出力してください。情報が見つからない項目はnullとしてください。
    `;
  }

  private async classifyWithOpenAI(prompt: string) {
    const response = await this.openai.chat.completions.create({
      model: 'gpt-4-turbo-preview',
      messages: [
        { role: 'system', content: 'あなたは情報抽出の専門家です。' },
        { role: 'user', content: prompt }
      ],
      response_format: { type: 'json_object' },
      temperature: 0.3, // 一貫性を重視
    });

    return this.parseAIResponse(response.choices[0].message.content);
  }

  private parseAIResponse(response: string): EmailClassificationResult {
    try {
      const parsed = JSON.parse(response);

      // 型安全性を確保
      return {
        classification: this.validateClassification(parsed.classification),
        projectTitle: parsed.projectTitle || undefined,
        skills: Array.isArray(parsed.skills) ? parsed.skills : [],
        ageRange: parsed.ageRange || undefined,
        salaryRange: parsed.salaryRange || undefined,
        commute: parsed.commute || undefined,
        bpRate: parsed.bpRate || undefined,
        businessDepth: parsed.businessDepth || undefined,
        requirements: Array.isArray(parsed.requirements)
          ? parsed.requirements
          : [],
      };
    } catch (error) {
      console.error('Failed to parse AI response:', error);
      return { classification: 'OTHER' };
    }
  }

  private estimateTokens(content: string): number {
    // 簡易トークン数推定(日本語は約0.5文字/トークン)
    return Math.ceil(content.length / 0.5);
  }

  private calculateCost(tokens: number, provider: string): number {
    // GPT-4 Turbo: $0.01 / 1K input tokens, $0.03 / 1K output tokens
    // 出力は入力の約1/3と仮定
    const inputCost = (tokens / 1000) * 0.01;
    const outputCost = (tokens / 3000) * 0.03;
    const totalUSD = inputCost + outputCost;

    // 円換算(1ドル=150円と仮定)
    return totalUSD * 150;
  }

  private fallbackClassification(
    content: string
  ): EmailClassificationResult {
    // ルールベースの簡易分類
    const hasProjectKeywords = /案件|プロジェクト|募集/.test(content);
    const hasPersonKeywords = /人材|エンジニア|技術者/.test(content);

    return {
      classification: hasProjectKeywords
        ? 'PROJECT'
        : hasPersonKeywords
        ? 'PERSON'
        : 'OTHER',
    };
  }
}

2. 範囲値の自動解析と正規化

「30〜40歳」のような文字列をmin/max値に変換する処理を実装:

// range-parser.ts
interface RangeResult {
  min?: number;
  max?: number;
}

class RangeParser {
  // 年齢範囲の解析
  parseAgeRange(ageRange: string | null): RangeResult {
    if (!ageRange) return {};

    // パターンマッチング
    const patterns = [
      // "30〜40歳", "30~40歳", "30-40歳"
      /(\d+)[〜~\-~](\d+)?/,
      // "30代前半"
      /(\d+)代前半/,
      // "30代後半"
      /(\d+)代後半/,
      // "30代"
      /(\d+)/,
      // "30歳以上"
      /(\d+)?以上/,
      // "40歳まで", "40歳以下"
      /(\d+)?(まで|以下)/,
    ];

    for (const pattern of patterns) {
      const match = ageRange.match(pattern);
      if (match) {
        return this.processAgeMatch(match, pattern);
      }
    }

    return {};
  }

  private processAgeMatch(match: RegExpMatchArray, pattern: RegExp): RangeResult {
    // "30〜40歳"パターン
    if (pattern.source.includes('[〜~\\-~]')) {
      return {
        min: parseInt(match[1]),
        max: parseInt(match[2])
      };
    }

    // "30代前半"パターン
    if (pattern.source.includes('代前半')) {
      const base = parseInt(match[1]) * 10;
      return { min: base, max: base + 4 };
    }

    // "30代後半"パターン
    if (pattern.source.includes('代後半')) {
      const base = parseInt(match[1]) * 10;
      return { min: base + 5, max: base + 9 };
    }

    // "30代"パターン
    if (pattern.source.includes('代$')) {
      const base = parseInt(match[1]) * 10;
      return { min: base, max: base + 9 };
    }

    // "30歳以上"パターン
    if (pattern.source.includes('以上')) {
      return { min: parseInt(match[1]) };
    }

    // "40歳まで"パターン
    if (pattern.source.includes('(まで|以下)')) {
      return { max: parseInt(match[1]) };
    }

    return {};
  }

  // 給与範囲の解析
  parseSalaryRange(salaryRange: string | null): RangeResult {
    if (!salaryRange) return {};

    // 単位を統一(月額換算)
    let normalizedRange = salaryRange;

    // 年収を月額に変換
    const yearlyMatch = salaryRange.match(/年収(\d+)??/);
    if (yearlyMatch) {
      const yearly = parseInt(yearlyMatch[1]);
      const monthly = Math.round(yearly / 12);
      normalizedRange = normalizedRange.replace(
        yearlyMatch[0],
        `${monthly}`
      );
    }

    // 月額のパターンマッチング
    const patterns = [
      // "月50〜70万"
      /?(\d+)[〜~\-~](\d+)/,
      // "月50万以上"
      /?(\d+)万以上/,
      // "月70万まで"
      /?(\d+)(まで|以下)/,
    ];

    for (const pattern of patterns) {
      const match = normalizedRange.match(pattern);
      if (match) {
        if (pattern.source.includes('[〜~\\-~]')) {
          return {
            min: parseInt(match[1]),
            max: parseInt(match[2])
          };
        }
        if (pattern.source.includes('以上')) {
          return { min: parseInt(match[1]) };
        }
        if (pattern.source.includes('(まで|以下)')) {
          return { max: parseInt(match[1]) };
        }
      }
    }

    return {};
  }

  // BP率の解析
  parseBpRate(bpRate: string | null): number | undefined {
    if (!bpRate) return undefined;

    // "BP65%", "BP率70%以上"などから数値を抽出
    const match = bpRate.match(/(\d+)%/);
    if (match) {
      return parseInt(match[1]);
    }

    return undefined;
  }

  // 商流深度の解析
  parseBusinessDepth(businessDepth: string | null): number | undefined {
    if (!businessDepth) return undefined;

    const depthMap: Record<string, number> = {
      '元請け': 1,
      '元請': 1,
      'プライム': 1,
      '2次請け': 2,
      '二次請け': 2,
      '2次': 2,
      '3次請け': 3,
      '三次請け': 3,
      '3次': 3,
    };

    // 完全一致を試みる
    if (depthMap[businessDepth]) {
      return depthMap[businessDepth];
    }

    // 部分一致を試みる
    for (const [key, value] of Object.entries(depthMap)) {
      if (businessDepth.includes(key)) {
        return value;
      }
    }

    return undefined;
  }
}

3. バッチ処理基盤の実装

大量のメールデータを効率的に処理するバッチ処理システム:

// batch-processor.ts
class MinMaxBatchProcessor {
  private rangeParser = new RangeParser();
  private batchSize = 100;
  private concurrency = 5;

  async processAllEmails(): Promise<void> {
    console.log('Starting batch processing...');

    let processedCount = 0;
    let errorCount = 0;
    let hasMore = true;
    let cursor: string | undefined;

    while (hasMore) {
      // カーソルベースのページネーション
      const batch = await this.getEmailBatch(cursor, this.batchSize);

      if (batch.length === 0) {
        hasMore = false;
        break;
      }

      // 並行処理でバッチを処理
      const results = await this.processBatchConcurrently(
        batch,
        this.concurrency
      );

      processedCount += results.filter(r => r.success).length;
      errorCount += results.filter(r => !r.success).length;

      // 進捗表示
      console.log(
        `Processed: ${processedCount}, Errors: ${errorCount}`
      );

      // 次のカーソル位置を更新
      cursor = batch[batch.length - 1].id;

      // レート制限対策
      await this.sleep(100);
    }

    console.log(`
      Batch processing completed:
      - Total processed: ${processedCount}
      - Errors: ${errorCount}
    `);
  }

  private async processBatchConcurrently(
    emails: Email[],
    concurrency: number
  ): Promise<ProcessResult[]> {
    const results: ProcessResult[] = [];

    // セマフォパターンで並行数を制限
    const semaphore = new Semaphore(concurrency);

    const promises = emails.map(async (email) => {
      await semaphore.acquire();

      try {
        const result = await this.processEmail(email);
        results.push(result);
      } finally {
        semaphore.release();
      }
    });

    await Promise.all(promises);
    return results;
  }

  private async processEmail(email: Email): Promise<ProcessResult> {
    try {
      const classification = email.emailClassificationResult;
      if (!classification) {
        return { success: true, skipped: true };
      }

      // 範囲値を解析
      const ageRange = this.rangeParser.parseAgeRange(
        classification.ageRange
      );
      const salaryRange = this.rangeParser.parseSalaryRange(
        classification.salaryRange
      );
      const bpRate = this.rangeParser.parseBpRate(
        classification.bpRate
      );
      const businessDepth = this.rangeParser.parseBusinessDepth(
        classification.businessDepth
      );

      // データベースを更新
      await prisma.email.update({
        where: { id: email.id },
        data: {
          ageMin: ageRange.min,
          ageMax: ageRange.max,
          salaryMin: salaryRange.min,
          salaryMax: salaryRange.max,
          bpRate,
          contractLayers: businessDepth,
          commuteCondition: this.normalizeCommute(
            classification.commute
          ),
        }
      });

      return { success: true };
    } catch (error) {
      console.error(`Failed to process email ${email.id}:`, error);
      return {
        success: false,
        error: error.message
      };
    }
  }

  private normalizeCommute(commute: string | null): string | null {
    if (!commute) return null;

    const commuteMap: Record<string, string> = {
      'フルリモート': 'FULL_REMOTE',
      '完全リモート': 'FULL_REMOTE',
      '在宅': 'FULL_REMOTE',
      '通勤月1': 'MONTHLY_1',
      '通勤月2': 'MONTHLY_2',
      '通勤週1': 'WEEKLY_1',
      '通勤週2': 'WEEKLY_2',
      '通勤週3': 'WEEKLY_3',
    };

    for (const [key, value] of Object.entries(commuteMap)) {
      if (commute.includes(key)) {
        return value;
      }
    }

    return commute; // 正規化できない場合は元の値を保持
  }

  // メモリ効率的なバッチ取得
  // Prismaのクエリ結果はキャッシュされないよう、各クエリで新しいインスタンスを使用
  private async getEmailBatch(
    cursor?: string,
    limit: number = 100
  ): Promise<Email[]> {
    const where: Prisma.EmailWhereInput = {
      // まだ処理されていないレコードのみ
      AND: [
        { emailClassificationResult: { isNot: null } },
        { ageMin: null },
        { salaryMin: null }
      ]
    };

    if (cursor) {
      where.id = { gt: cursor };
    }

    // selectを使って必要な最小限のフィールドのみ取得
    const emails = await prisma.email.findMany({
      where,
      orderBy: { id: 'asc' },
      take: limit,
      select: {
        id: true,
        emailClassificationResult: true,
      }
    });

    // メモリを解放(重要:大量データ処理時)
    if (global.gc) {
      global.gc();
    }

    return emails;
  }

  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// セマフォ実装(並行数制限)
class Semaphore {
  private permits: number;
  private waiting: (() => void)[] = [];

  constructor(permits: number) {
    this.permits = permits;
  }

  async acquire(): Promise<void> {
    if (this.permits > 0) {
      this.permits--;
      return;
    }

    return new Promise<void>(resolve => {
      this.waiting.push(resolve);
    });
  }

  release(): void {
    this.permits++;

    if (this.waiting.length > 0) {
      const resolve = this.waiting.shift();
      if (resolve) {
        this.permits--;
        resolve();
      }
    }
  }
}

4. 手動実行用のCLIツール

運用しやすいCLIツールも提供:

// scripts/populate-min-max.ts
import { program } from 'commander';

program
  .name('populate-min-max')
  .description('AI分類結果からmin/max値を生成')
  .option('-b, --batch-size <number>', 'バッチサイズ', '100')
  .option('-c, --concurrency <number>', '並行実行数', '5')
  .option('--dry-run', 'ドライラン(DBは更新しない)')
  .option('--from-date <date>', '処理開始日')
  .option('--to-date <date>', '処理終了日');

program.parse();

const options = program.opts();

async function main() {
  const processor = new MinMaxBatchProcessor({
    batchSize: parseInt(options.batchSize),
    concurrency: parseInt(options.concurrency),
    dryRun: options.dryRun,
    fromDate: options.fromDate ? new Date(options.fromDate) : undefined,
    toDate: options.toDate ? new Date(options.toDate) : undefined,
  });

  await processor.processAllEmails();
}

main().catch(console.error);

5. テストケースの充実

正規表現パーサーの信頼性を確保するため、包括的なテストを実装:

// range-parser.test.ts
import { describe, it, expect, test } from 'vitest';
import { RangeParser } from './range-parser';

describe('RangeParser', () => {
  const parser = new RangeParser();

  describe('parseAgeRange', () => {
    test.each([
      // 範囲表記のバリエーション
      ['30〜40歳', { min: 30, max: 40 }],
      ['30~40歳', { min: 30, max: 40 }], // 全角波ダッシュ
      ['30-40歳', { min: 30, max: 40 }],  // ハイフン
      ['30~40歳', { min: 30, max: 40 }],  // 半角チルダ

      // 年代表記
      ['20代前半', { min: 20, max: 24 }],
      ['20代後半', { min: 25, max: 29 }],
      ['30代', { min: 30, max: 39 }],

      // 以上・以下表記
      ['30歳以上', { min: 30, max: undefined }],
      ['40歳まで', { min: undefined, max: 40 }],
      ['40歳以下', { min: undefined, max: 40 }],

      // エッジケース
      ['', {}],
      [null, {}],
      ['不明', {}],
    ])(
      'parseAgeRange("%s") should return %o',
      (input, expected) => {
        expect(parser.parseAgeRange(input)).toEqual(expected);
      }
    );
  });

  describe('parseSalaryRange', () => {
    test.each([
      // 月額表記
      ['月50〜70万', { min: 50, max: 70 }],
      ['月50万以上', { min: 50, max: undefined }],
      ['月70万まで', { min: undefined, max: 70 }],

      // 年収表記(月額換算)
      ['年収600万円', { min: 50, max: undefined }], // 600/12=50
      ['年収720〜840万', { min: 60, max: 70 }],

      // 単位の省略
      ['50〜70万', { min: 50, max: 70 }],
      ['月50k〜70k', { min: 50, max: 70 }],
    ])(
      'parseSalaryRange("%s") should return %o',
      (input, expected) => {
        expect(parser.parseSalaryRange(input)).toEqual(expected);
      }
    );
  });

  describe('parseBpRate', () => {
    test.each([
      ['BP65%', 65],
      ['BP率70%以上', 70],
      ['商流65%', 65],
      ['', undefined],
      [null, undefined],
    ])(
      'parseBpRate("%s") should return %s',
      (input, expected) => {
        expect(parser.parseBpRate(input)).toBe(expected);
      }
    );
  });
});

学んだこと

意外だった落とし穴

  1. 日本語の表記ゆれ

    • 「〜」「~」「-」など、似た文字の違い
    • 全角・半角の混在
    • 単位の省略(「万円」「万」「k」など)
  2. AIレスポンスの不安定性

    • 同じプロンプトでも出力形式が変わることがある
    • temperatureを下げても完全には防げない
  3. バッチ処理のメモリリーク

    • 大量データ処理時のメモリ管理が重要
    • Prismaのクエリ結果をキャッシュしないよう注意

今後使えそうな知見

  1. プロンプトエンジニアリング

    // Few-shot学習で精度向上
    const examples = [
      { input: "30〜40歳", output: { min: 30, max: 40 } },
      { input: "20代後半", output: { min: 25, max: 29 } },
    ];
    
    const prompt = `
    以下の例を参考に、情報を抽出してください:
    ${examples.map(e => `入力: ${e.input} → 出力: ${JSON.stringify(e.output)}`).join('\n')}
    `;
    
  2. エラーリカバリー戦略

    class RetryableProcessor {
      async processWithRetry<T>(
        fn: () => Promise<T>,
        maxRetries: number = 3
      ): Promise<T> {
        let lastError: Error;
    
        for (let i = 0; i < maxRetries; i++) {
          try {
            return await fn();
          } catch (error) {
            lastError = error;
    
            // 指数バックオフ
            const delay = Math.pow(2, i) * 1000;
            await this.sleep(delay);
          }
        }
    
        throw lastError;
      }
    }
    

もっと良い書き方の発見

ストラテジーパターンでパーサーを拡張可能に

interface RangeParserStrategy {
  canParse(text: string): boolean;
  parse(text: string): RangeResult;
}

class AgeRangeParserStrategy implements RangeParserStrategy {
  private patterns = [/* ... */];

  canParse(text: string): boolean {
    return this.patterns.some(p => p.test(text));
  }

  parse(text: string): RangeResult {
    // 実装
  }
}

class RangeParserService {
  private strategies: RangeParserStrategy[] = [
    new AgeRangeParserStrategy(),
    new SalaryRangeParserStrategy(),
    // 新しいパーサーを簡単に追加可能
  ];

  parse(text: string, type: string): RangeResult {
    const strategy = this.strategies.find(s =>
      s.canParse(text)
    );

    return strategy?.parse(text) ?? {};
  }
}

終わりに

AI分類とバッチ処理基盤の構築により、メール管理システムの検索精度が大幅に向上しました。特に、自然言語で書かれた範囲表現を構造化データに変換することで、データベースレベルでの効率的な検索が可能になりました。

重要なのは、AIの出力を盲信せず、適切なバリデーションと正規化処理を組み合わせることです。また、大量データを扱う際は、メモリ効率とエラーハンドリングを考慮した設計が不可欠です。

皆さんも似たようなシステムを構築する際は、ぜひ段階的な処理とエラーリカバリーを意識してみてください。完璧を求めすぎず、8割の精度でも業務価値を提供できることが多いです。


この記事で紹介したコードは、実際のプロダクションコードを簡略化したものです。エラーハンドリングやセキュリティチェックなど、実際の実装では追加の考慮事項があります。

関連技術: OpenAI GPT-4, Claude 3, TypeScript, Prisma ORM, BullMQ, PostgreSQL, バッチ処理, 正規表現, Vitest

筆者: 91works開発チーム

91works Tech Blog

Discussion