🏋️

Mastraを使ってAIエージェントを作ろう! - 応用編 -

に公開

https://supporterz-seminar.connpass.com/event/371117/

はじめに

本ハンズオンでは、基礎編(1)(2)(3)のようなシンプルなAIエージェントから一段階ステップアップし、外部APIへのアクセス以外の様々な処理の実行を伴うAIエージェントの開発について解説します。

Mastraを使ってより高度な課題を解決できるようになり、自身の業務効率化に役立てられる直接的なヒントをみなさんに得てもらえると幸いです☺️

ハンズオンのステップ

  1. レシート画像のデータ化エージェントを作成する
  2. 作成したエージェントにCSVを生成するツールを追加し、CSV出力機能を追加する

事前に準備が必要なもの

  • Node.js(v20.0以上)
  • サポート対象のモデルプロバイダーのAPIキー
    • 本記事では、OpenAIのAPIキーを利用します

0. 事前準備

以下のテンプレートレポジトリから、assistant-ui製のチャットUI付きのエージェントレポジトリを作成します。

https://github.com/subroh0508/template-mastra-with-ui


レポジトリ名は「kakeibo-agent」とします

レポジトリの作成が完了しローカルにcloneできたら、動作確認をしておきましょう。環境変数OPENAI_API_KEY.envに設定すること、そしてnpm installを実行すること、どちらも忘れずに!

  • npm run dev
    assistant-ui製のチャットUI付き天気情報エージェントが起動します
  • npm run mastra:dev
    MastraのPlaygroundが起動します

1. レシート画像のデータ化エージェントを作成する

1-1. レシート画像をOpenAI APIに投げてデータ化するエージェントを実装

セクション名のように動作する、ReceiptAnalyzerAgentを実装します。

mastra/agents/receipt-analyzer-agent.ts
import { Agent } from '@mastra/core/agent';

export const receiptAnalyzerAgent = new Agent({
  name: 'Receipt Analyzer Agent',
  instructions: `
あなたはレシート画像を分析し、構造化されたJSONデータを抽出する専門家です。

画像から以下の情報を正確に読み取り、JSON形式で返してください:

【必須項目】
- storeName: 店舗名(レシート上部に記載されている店名)
- date: 購入日時(YYYY-MM-DDTHH:mm:ss形式。時刻が不明な場合は12:00:00を使用)
- items: 購入商品の配列。各商品には以下を含む:
  - name: 商品名
  - quantity: 数量
  - price: 単価
  - total: 小計(quantity × price)
- subtotal: 小計(税抜き合計)
- tax: 消費税額
- total: 合計金額(税込み)

【任意項目】
- paymentMethod: 支払い方法(現金、クレジットカード、電子マネーなど。判読できる場合のみ)

【重要な注意事項】
1. 日付フォーマットは必ずYYYY-MM-DDTHH:mm:ss形式で統一してください
2. 時刻情報がレシートに記載されていない場合は、12:00:00を使用してください
3. 金額は全て数値型で返してください(カンマや円記号は含めない)
4. 商品名は可能な限り正確に読み取ってください
5. 数量が明記されていない場合は1として扱ってください
6. 税込み・税抜きの区別に注意してください
7. レシートが不鮮明で読み取れない項目がある場合は、その旨を説明してください

【出力形式】
必ず以下のJSON形式で回答してください:

\`\`\`json
{
  "storeName": "店舗名",
  "date": "YYYY-MM-DDTHH:mm:ss",
  "items": [
    {
      "name": "商品名",
      "quantity": 1,
      "price": 100,
      "total": 100
    }
  ],
  "subtotal": 100,
  "tax": 10,
  "total": 110,
  "paymentMethod": "現金"
}
\`\`\`

画像が提供されたら、上記の形式で情報を抽出してください。
`,
  model: 'openai/gpt-4o-mini',
  tools: {},
});
ざっくり仕様を含めたコード生成プロンプト

agentsディレクトリ以下に、レシート画像のOCR結果をJSONで返すAgentクラスを作成します。返す情報は、以下の項目に沿うようにしてください。

必須項目:

  • storeName: 店舗名
  • date: 購入日時 (YYYY-MM-DDTHH:mm:ss形式、時刻不明なら12:00:00)
  • items: [{name: 商品名, quantity: 数量, price: 単価, total: 小計}]
  • subtotal: 小計
  • tax: 消費税
  • total: 合計金額

任意項目:

  • paymentMethod: 支払い方法(判読できる場合のみ)

また、Toolは一切実装せず、instructionsに渡すプロンプトでのみ動作が定義される形式としてください。
プロンプトは全て日本語で記載してください。

1-2. assistant-ui上で動作確認ができるよう、コードを修正

routes.tsから呼び出すAIエージェントをWeatherAgentからReceiptAnalyzerAgentに差し替え、動作確認をしてみましょう!

mastra/index.ts
@@ -3,11 +3,12 @@
 import { weatherWorkflow } from './workflows/weather-workflow';
 import { weatherAgent } from './agents/weather-agent';
+import { receiptAnalyzerAgent } from './agents/receipt-analyzer-agent';
 
 
 export const mastra = new Mastra({
   workflows: { weatherWorkflow },
-  agents: { weatherAgent },
+  agents: { weatherAgent, receiptAnalyzerAgent },
   logger: new PinoLogger({
     name: 'Mastra',
app/api/chat/route.ts
@@ -7,8 +7,7 @@
 export async function POST(req: Request) {
   const { messages }: { messages: UIMessage[] } = await req.json();
-
-  const agent = mastra.getAgent("weatherAgent");
+  const agent = mastra.getAgent("receiptAnalyzerAgent");
   const result = await agent.stream(messages);

いとも簡単に、レシート画像から購入日時や商品名といった情報を読み取り、JSON形式で表示することができました🎉

2. 作成したエージェントにCSVを生成するツールを追加し、CSV出力機能を追加する

2-1. (ツールを作る前に)プロンプトを修正してCSV出力機能を追加してみる

まずはプロンプトを修正して、CSV出力機能を追加してみましょう。

mastra/agents/receipt-analyzer-agent.ts
@@ -5,7 +5,7 @@
 export const receiptAnalyzerAgent = new Agent({
   instructions: `
 あなたはレシート画像を分析し、構造化されたJSONデータを抽出する専門家です。
 
-画像から以下の情報を正確に読み取り、JSON形式で返してください:
+画像から以下の情報を正確に読み取ってください:
 
 【必須項目】
 - storeName: 店舗名(レシート上部に記載されている店名)

@@ -32,25 +32,11 @@
 7. レシートが不鮮明で読み取れない項目がある場合は、その旨を説明してください
 
 【出力形式】
-必ず以下のJSON形式で回答してください:
-
-\`\`\`json
-{
-  "storeName": "店舗名",
-  "date": "YYYY-MM-DDTHH:mm:ss",
-  "items": [
-    {
-      "name": "商品名",
-      "quantity": 1,
-      "price": 100,
-      "total": 100
-    }
-  ],
-  "subtotal": 100,
-  "tax": 10,
-  "total": 110,
-  "paymentMethod": "現金"
-}
+必ず以下のカンマ区切りCSVで出力してください:
+
+\`\`\`
+購入日時,店舗名,商品名,数量,単価,小計,消費税,合計金額,支払い方法
+YYYY-MM-DDTHH:mm:ss,店舗名,商品名,1,1,100,100,10,110,現金
 \`\`\`
 
 画像が提供されたら、上記の形式で情報を抽出してください。

以上のように、プロンプトを修正するだけでも、画面への出力をJSONからカンマ区切りCSVへと変更することは難なく実現可能です。

ただし、プロンプトでの機能修正にはいくつか欠点も存在します。代表的なものでいえば、

  • AIに生成させているため、出力を確実に固定することができない(CSVのヘッダ情報など)
  • 複雑な出力方式への対応が難しい(クラウドストレージへのアップロード、ローカルファイル生成など)[1]

などが挙げられます。

2-2. 読み取ったレシート情報をCSVファイルに出力するCsvWriterToolを実装する

では実際に、このAIエージェントをファイル出力に対応させるため、必要なToolを実装してみましょう!

mastra/tools/csv-writer-tool.ts
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
import * as fs from 'fs/promises';
import * as path from 'path';

const itemSchema = z.object({
  name: z.string().describe('商品名'),
  quantity: z.number().describe('数量'),
  price: z.number().describe('単価'),
  total: z.number().describe('小計'),
});

export const csvWriterTool = createTool({
  id: 'write-receipt-csv',
  description: 'レシートデータをCSV形式のファイルに書き込む',
  inputSchema: z.object({
    storeName: z.string().describe('店舗名'),
    date: z.string().describe('購入日時 (YYYY-MM-DDTHH:mm:ss形式)'),
    items: z.array(itemSchema).describe('購入商品の配列'),
    subtotal: z.number().describe('小計'),
    tax: z.number().describe('消費税'),
    total: z.number().describe('合計金額'),
    paymentMethod: z.string().optional().describe('支払い方法'),
  }),
  outputSchema: z.object({
    success: z.boolean().describe('記録が成功したかどうか'),
    message: z.string().describe('処理結果のメッセージ'),
    filePath: z.string().describe('記録されたファイルのパス'),
    recordedCount: z.number().describe('記録された行数'),
  }),
  execute: async ({ context }) => {
    return await writeReceiptToCSV(context);
  },
});

interface ReceiptData {
  storeName: string;
  date: string;
  items: Array<{
    name: string;
    quantity: number;
    price: number;
    total: number;
  }>;
  subtotal: number;
  tax: number;
  total: number;
  paymentMethod?: string;
}

const writeReceiptToCSV = async (data: ReceiptData) => {
  try {
    // データディレクトリとファイルパスの設定
    const dataDir = path.join(process.cwd(), 'data');
    const filePath = path.join(dataDir, 'receipts.csv');

    // データディレクトリが存在しない場合は作成
    await fs.mkdir(dataDir, { recursive: true });

    // ファイルの存在確認
    let fileExists = false;
    try {
      await fs.access(filePath);
      fileExists = true;
    } catch {
      fileExists = false;
    }

    // CSVヘッダー
    const headers = [
      '店舗名',
      '購入日時',
      '商品名',
      '数量',
      '単価',
      '商品小計',
      '小計',
      '消費税',
      '合計金額',
      '支払い方法',
    ].join(',');

    // CSVデータ行の作成
    const rows: string[] = [];
    data.items.forEach((item, index) => {
      const row = [
        escapeCSV(data.storeName),
        escapeCSV(data.date),
        escapeCSV(item.name),
        item.quantity.toString(),
        item.price.toString(),
        item.total.toString(),
        // 最初の商品行にのみ小計・税・合計を記録
        index === 0 ? data.subtotal.toString() : '',
        index === 0 ? data.tax.toString() : '',
        index === 0 ? data.total.toString() : '',
        index === 0 ? escapeCSV(data.paymentMethod || '') : '',
      ].join(',');
      rows.push(row);
    });

    // ファイルへの書き込み
    let content = '';
    if (!fileExists) {
      // ファイルが存在しない場合はヘッダーを追加
      content = headers + '\n' + rows.join('\n') + '\n';
    } else {
      // ファイルが存在する場合は追記
      content = rows.join('\n') + '\n';
    }

    await fs.appendFile(filePath, content, 'utf-8');

    return {
      success: true,
      message: `レシートデータを正常に記録しました。${data.items.length}件の商品を記録しました。`,
      filePath: filePath,
      recordedCount: data.items.length,
    };
  } catch (error) {
    return {
      success: false,
      message: `CSV書き込み中にエラーが発生しました: ${error instanceof Error ? error.message : String(error)}`,
      filePath: '',
      recordedCount: 0,
    };
  }
};

/**
 * CSV用に文字列をエスケープする
 * カンマ、改行、ダブルクォートを含む場合はダブルクォートで囲む
 */
const escapeCSV = (value: string): string => {
  if (!value) return '';

  // カンマ、改行、ダブルクォートを含む場合
  if (value.includes(',') || value.includes('\n') || value.includes('"')) {
    // ダブルクォートを2つにエスケープして、全体をダブルクォートで囲む
    return `"${value.replace(/"/g, '""')}"`;
  }

  return value;
};
ざっくり仕様を含めたコード生成プロンプト

toolディレクトリ以下に、受け取ったデータをCSV形式のファイルに書き込むToolクラスを作成します。

Toolが受け取る入力値は、以下のような仕様とします。
必須項目:

  • storeName: 店舗名
  • date: 購入日時 (YYYY-MM-DDTHH:mm:ss形式、時刻不明なら12:00:00)
  • items: [{name: 商品名, quantity: 数量, price: 単価, total: 小計}]
  • subtotal: 小計
  • tax: 消費税
  • total: 合計金額

任意項目:

  • paymentMethod: 支払い方法

また、Toolの出力形式は以下の通りでお願いします。
必須項目:

  • success: 記録が成功したかどうか
  • message: 処理結果のメッセージ
  • filePath: 記録されたファイルのパス
  • recordedCount: 記録された行数

動作確認をしてみましょう。CsvWriterToolは非常に単純な作りのため、Playgroundで動作確認ができます。

npm run mastra:devでPlaygroundを立ち上げ、フォームにテストデータを入力してみましょう。Submitボタンを押すと、Toolが実行されます。


2-3. CsvWriterToolを利用するよう、ReceiptAnalyzerAgentのプロンプトを修正する

ReceiptAnalyzerAgentにToolを認識させ、プロンプトを修正します。

mastra/agents/receipt-analyzer-agent.ts
@@ -1,13 +1,19 @@
 import { Agent } from '@mastra/core/agent';
+import { csvWriterTool } from '../tools/csv-writer-tool';
 
 export const receiptAnalyzerAgent = new Agent({
   name: 'Receipt Analyzer Agent',
   instructions: `
-あなたはレシート画像を分析し、構造化されたJSONデータを抽出する専門家です。
+あなたはレシート画像を分析し、家計簿CSVファイルに記録する専門家です。
 
-画像から以下の情報を正確に読み取り、JSON形式で返してください:
+【作業手順】
+1. レシート画像から以下の情報を正確に読み取る
+2. 読み取ったデータをwrite-receipt-csvツールを使って家計簿CSVファイルに保存する
+3. 保存結果をユーザーに報告する
 
-【必須項目】
+【画像から読み取る情報】
+
+必須項目:
 - storeName: 店舗名(レシート上部に記載されている店名)
 - date: 購入日時(YYYY-MM-DDTHH:mm:ss形式。時刻が不明な場合は12:00:00を使用)
 - items: 購入商品の配列。各商品には以下を含む:

@@ -19,7 +25,7 @@
 export const receiptAnalyzerAgent = new Agent({
 - tax: 消費税額
 - total: 合計金額(税込み)
 
-【任意項目】
+任意項目:
 - paymentMethod: 支払い方法(現金、クレジットカード、電子マネーなど。判読できる場合のみ)
 
 【重要な注意事項】

@@ -31,30 +37,13 @@
 export const receiptAnalyzerAgent = new Agent({
 6. 税込み・税抜きの区別に注意してください
 7. レシートが不鮮明で読み取れない項目がある場合は、その旨を説明してください
 
-【出力形式】
-必ず以下のJSON形式で回答してください:
-
-\`\`\`json
-{
-  "storeName": "店舗名",
-  "date": "YYYY-MM-DDTHH:mm:ss",
-  "items": [
-    {
-      "name": "商品名",
-      "quantity": 1,
-      "price": 100,
-      "total": 100
-    }
-  ],
-  "subtotal": 100,
-  "tax": 10,
-  "total": 110,
-  "paymentMethod": "現金"
-}
-\`\`\`
-
-画像が提供されたら、上記の形式で情報を抽出してください。
+【処理フロー】
+1. レシート画像から上記の情報を抽出
+2. 抽出したデータをwrite-receipt-csvツールに渡して家計簿CSVファイルに保存
+3. ツールからの結果(success, message, filePath, recordedCount)をユーザーに報告
+
+必ずwrite-receipt-csvツールを使用してデータを保存してください。
 `,
   model: 'openai/gpt-4o-mini',
-  tools: {},
+  tools: { csvWriterTool },
 });
修正指示のプロンプト

ReceiptAnalyzerAgentがCsvWriterToolを利用し、レシート画像の読み取り結果を家計簿CSVファイルに出力するよう修正してください。

全ての修正が完了すると、レシート画像から家計簿CSVファイルを生成するタスクがチャットUIから実行できるようになります😎

まとめ

画像認識・ファイル出力といった処理を伴う、家計簿AIエージェントを実装することができました!

AIを狙い通りに動作させ、AIエージェントの品質を高めるには、今回のように 「AIに任せるタスク」と「コード実行で解決するタスク」を分けて捉えることが非常に重要です。

なお、今回実装した家計簿AIエージェントは、CSVファイルをローカルに生成している都合上、そのままではVercel上にデプロイすることはできません。例えば、Google Sheets APIで読み取った情報をクラウド上にアップロードするなど、CsvWriterToolの処理を修正すると、本番環境でも利用可能な家計簿エージェントへと進化させられるので、是非チャレンジしてみてください😉


レポジトリはこちら
https://github.com/subroh0508/kakeibo-agent

この記事での変更をまとめたPull Requestはこちら
https://github.com/subroh0508/kakeibo-agent/pull/1

脚注
  1. 頑張ればできるかもしれないが、そんな努力をするよりコードを書いた方が早いぞ😇 ↩︎

TOKIUMプロダクトチーム テックブログ

Discussion