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?

React + Spring Bootの自作家計簿アプリに、Gemini APIでレシート自動入力機能を実装してみた

0
Posted at

僕だけが利用する家計簿アプリを開発しているのですが、毎回レシートを見ながら「食費何円、日用品何円...」と手動で入力するのがだんだん面倒くさくなり、記録するのを怠けてしまうことがありました。
そこで、
「レシート画像をアップロードして、AIに自動で日付・商品・金額・カテゴリを判別してもらい自動入力してもらえばいいやん!」
と思いGemini APIを家計簿内に実装してみましたので、その実装手順やプロンプトの工夫などを備忘録としてこの記事に残したいと思います。

なぜGemini APIなのか?

OCRライブラリを使用することも考えましたが、実装がめんどくさいのと、そもそもAIがうまいこと全部やってくれるだろうと思いGemini APIを使用しました。
また、個人で利用する家計簿アプリなので、1日のレシート読み込み回数は多くないです。
上位モデルを使用しなければ基本無料で利用できる点も大きいです。
今回はgemini-2.5-flashを使用しました。レート制限は下記です。
・1分あたりのリクエスト数(RPM):5
・1分あたりのトークン数(入力)(TPM):250K
・1日あたりのリクエスト数(RPD):20
十分です。

【準備】Gemini APIキーの作成

Gemini APIを使用するために、Google AI Studioにアクセスし、API Keyを作成する必要があります。

  1. Google AI Studioにアクセスし、ログインします
  2. 画面左下の方にGet API keyがあるので、そちらをクリック
  3. APIキー画面に遷移したら、画面右上の方にAPIキーを作成ボタンがあるので、そちらをクリック
  4. 後は画面の指示通りにキー名とプロジェクトを選択して作成する

環境変数にAPIキーの設定

コードにAPIキーをハードコードしないために、環境変数から読み込むように設定します。
今回の環境はWindowsなので、システムの環境変数に設定しました。

  1. 変数名をGEMINI_API_KEYと指定
  2. 上記で作成したGemini APIキーを変数値として指定

システム構成と処理フロー

処理の流れはシンプルに以下のように設計しました。

  1. フロントエンド(React):レシート画像をドラッグ&ドロップでアップロードする
  2. バックエンド(Spring Boot/Java):画像を受け取り、Base64形式にエンコード。DBから現在のカテゴリ一覧を取得する
  3. AI(Gemini API):画像とカテゴリ一覧をGeminiに渡し、JSON形式で解析データを取得する
  4. フロントエンド(React):解析結果を確認・修正できるモーダルを表示し、OKなら家計簿に登録する

Javaの実装

API キーが取得できたら、Spring Boot でレシート画像を受け取って Gemini API にリクエストを送るコントローラーを実装します。

レシート解析用 DTO の作成

Gemini から JSON 形式で結果を受け取るため、データ構造(DTO)を定義します。

// ReceiptScanItem.java (明細)
public class ReceiptScanItem {
    private String category;    // カテゴリ名
    private Integer amount;     // 金額
    private String description; // 店舗名や品名
    // getter/setter/constructor 省略
}

// ReceiptScanResult.java (全体の解析結果)
public class ReceiptScanResult {
    private String date; // YYYY-MM-DD
    private List<ReceiptScanItem> items;
    private Integer totalAmount;
    // getter/setter/constructor 省略
}

コントローラーの実装

Java21のHttpClientを使いgemini-2.5-flashモデルへ画像データとテキストプロンプトを送信します。
プロンプトでは、DBに登録されているカテゴリ名の一覧を動的に埋め込み、AIにその中から分類させます。

@RestController
@RequestMapping("/api/receipt")
public class ReceiptScannerController {

  @Value("${gemini.api.key}")
  private String geminiApiKey;

  @Autowired
  private CategoryDefRepository categoryRepository;

  @Autowired
  private ObjectMapper objectMapper;

  private final HttpClient httpClient = HttpClient.newHttpClient();

  @PostMapping("/scan")
  public ResponseEntity<?> scanReceipt(@RequestParam("file") MultipartFile file) {
    String apiKey = geminiApiKey;
    if (apiKey == null || apiKey.trim().isEmpty()) {
      apiKey = System.getenv("GEMINI_API_KEY");
    }

    try {
      // カテゴリ一覧を取得してプロンプトに動的に埋め込む
      List<String> categories = categoryRepository.findAll().stream()
          .map(CategoryDef::getName)
          .toList();
      String categoryListStr = String.join(", ", categories);

      // プロンプトの構築
      String prompt = "あなたは優秀な家計簿アシスタントです。アップロードされたレシート画像を解析し、以下のカテゴリリストに当てはまる支出項目を抽出してください。\n" +
              "カテゴリリスト: [" + categoryListStr + "]\n\n" +
              "【レシート解析および計算の汎用ルール】\n" +
              "1. 最終支払合計金額の特定 (これを totalAmount とする)\n" +
              "2. 各商品の最終税込・割引後価格の算出 (金額の総和が totalAmount と1円単位で完全に一致するように調整すること)\n\n" +
              "以下のJSONフォーマットのみで返答してください。\n" +
              "{\n" +
              "  \"date\": \"YYYY-MM-DD\",\n" +
              "  \"items\": [\n" +
              "    { \"category\": \"カテゴリ名\", \"amount\": 100, \"description\": \"品名\" }\n" +
              "  ],\n" +
              "  \"totalAmount\": 100\n" +
              "}";

      // 画像を Base64 エンコード
      String base64Image = Base64.getEncoder().encodeToString(file.getBytes());
      String contentType = file.getContentType() != null ? file.getContentType() : "image/jpeg";

      // リクエストボディの組み立て
      Map<String, Object> inlineData = Map.of("mimeType", contentType, "data", base64Image);
      Map<String, Object> textPart = Map.of("text", prompt);
      Map<String, Object> imagePart = Map.of("inlineData", inlineData);
      Map<String, Object> partContainer = Map.of("parts", List.of(textPart, imagePart));
      Map<String, Object> generationConfig = Map.of("responseMimeType", "application/json");

      Map<String, Object> requestBodyMap = Map.of(
          "contents", List.of(partContainer),
          "generationConfig", generationConfig
      );
      String requestBodyJson = objectMapper.writeValueAsString(requestBodyMap);

      // API 呼び出し
      String url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + apiKey;
      HttpRequest request = HttpRequest.newBuilder()
          .uri(URI.create(url))
          .header("Content-Type", "application/json")
          .POST(HttpRequest.BodyPublishers.ofString(requestBodyJson))
          .build();

      HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());

      // JSONレスポンスのパース
      JsonNode root = objectMapper.readTree(response.body());
      String responseText = root.path("candidates").path(0).path("content").path("parts").path(0).path("text").asText();
      ReceiptScanResult result = objectMapper.readValue(responseText, ReceiptScanResult.class);

      return ResponseEntity.ok(result);
    } catch (Exception e) {
      return ResponseEntity.internalServerError().body(Map.of("message", e.getMessage()));
    }
  }
}

フロントエンドの実装 (React + TypeScript)

フロントエンドでは、画像をアップロードするドラッグ&ドロップ領域を持つモーダル UI を用意します。
APIからの返り値である「スキャン結果(日付・品目リスト)」を一度フォームに反映させ、画面上で1品ずつ微調整できるようにしています。

データの登録処理(カテゴリ毎の集計)

AIが正しく認識しているか画面上で1品ずつ確認・編集できるようにしつつ、登録ボタンを押したタイミングで、フロントエンド側でカテゴリ毎に自動で集計(合算)して登録します。
これにより、履歴が商品ごとに細切れになってしまうのを防ぎつつ、カテゴリ別の正確な家計簿データを登録できます。

const handleRegister = async () => {
  if (formItems.length === 0) return;
  setIsSaving(true);
  try {
    // カテゴリごとに金額を集計し、商品名をカンマ区切りで結合する
    const grouped: { [category: string]: { amount: number; descList: string[] } } = {};
    
    for (const item of formItems) {
      const cat = item.category || "未分類";
      if (!grouped[cat]) {
        grouped[cat] = { amount: 0, descList: [] };
      }
      grouped[cat].amount += Number(item.amount || 0);
      if (item.description && item.description.trim()) {
        grouped[cat].descList.push(item.description.trim());
      }
    }

    // 集計した結果を登録用の取引リストに変換
    const newTxList = Object.keys(grouped).map((cat) => {
      const group = grouped[cat];
      const uniqueDescs = Array.from(new Set(group.descList));
      const combinedDesc = uniqueDescs.join(", ");
      
      return {
        date: scanDate || defaultDate,
        amount: group.amount,
        category: cat,
        description: combinedDesc || "レシート一括登録",
        paidBy,
        isSettlementTarget,
      };
    });

    // 一括登録
    await onAddTransactions(newTxList);
    onSuccess(`${newTxList.length}件の支出をカテゴリ毎にまとめて登録しました!`);
    onClose();
    // クリーンアップ処理
    setSelectedFile(null);
    setPreviewUrl(null);
    setScanResult(null);
  } catch (err) {
    onError("家計簿への登録に失敗しました");
  } finally {
    setIsSaving(false);
  }
};

動かしてみた

AIスキャンボタン
image.png
レシートアップロード初期画面
3スクリーンショット 2026-06-07 174125.png
レシートアップロード
4スクリーンショット 2026-06-07 174208.png
AI解析中
markup_1767.png
解析結果
2.jpg
履歴に反映
image.png

まとめ

Google AI StudioのGemini APIを活用することで、わずか数十行のプロンプトとJava標準のHttpClientだけで、実用的なレシートスキャン機能を自作家計簿に実装することができました。
特に、税込み・税抜き・割引が複雑に絡む日本のレシートの計算ズレを「合計金額と一致させる」プロンプトの工夫だけで解消できたのは、LLMの賢さを実感しました。
AIしゅごい

参考URL

Gemini API 公式ドキュメント

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?