Python × Gemini API で「経費仕分けツール」を作った話 〜ルール判定とAI判定のハイブリッド設計〜
はじめに
個人事業主やフリーランスは、毎月のカード明細を手作業で勘定科目に仕分けている人が多いのではないでしょうか。この作業を自動化できないかと考え、Python + Gemini API + openpyxl + customtkinter を使って、経費仕分けツールを自作してみました。
※本記事は学習・実績作りを目的とした、想定課題に基づく個人開発です。
概要
カード明細のCSVを読み込み、以下の流れで勘定科目まで仕分けたExcelファイルを出力するデスクトップツールです。
- CSVを読み込む
- あらかじめ用意したルール辞書で、店名から勘定科目を判定する
- ルールに当てはまらなかった行だけ、Gemini APIに勘定科目を判定してもらう
- 判定結果をopenpyxlでExcelに出力する
- customtkinterのGUIから、ファイル選択・実行・結果確認までをワンクリックで行える
使用技術
- Python 3.13
- google-genai(Gemini APIクライアント)
- openpyxl
- customtkinter
- tkinter.filedialog
- threading
すべて無料で開発・運用できる構成にしています。
実装
1. CSVの読み込み
カード明細の金額は"2,480"のようにカンマ区切りの文字列で入ってくるため、数値に変換する処理が必要でした。
import csv
def load_expenses(path):
"""CSVを読み込んで、辞書のリストを返す"""
expenses = []
with open(path, "r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
row["金額"] = int(row["金額"].replace(",", ""))
expenses.append(row)
return expenses
csv.DictReaderで1行ずつ見出し付きの辞書として読み込み、金額列だけカンマを除去してintに変換しています。encoding="utf-8-sig"は、Excel経由で保存したCSVの先頭に付くBOM対策、newline=""はCSVモジュールの推奨設定です。
2. ルール辞書による判定
すべての行をAIに判定させると、無料枠の呼び出し回数を圧迫します。そこで、店名にキーワードが含まれるかで先に判定するルール辞書を用意しました。
RULES = {
"旅費交通費": ["JR", "Suica", "タクシー"],
"消耗品費": ["Amazon", "ヨドバシ", "書店"],
"通信費": ["ドコモ", "au", "SoftBank"],
"会議費": ["スターバックス", "ドトール", "居酒屋"],
"水道光熱費": ["ENEOS", "東京電力", "ガス"],
"新聞図書費": [],
}
def classify(shop_name):
"""店名を受け取り、勘定科目を返す。一致しなければNoneを返す"""
for category, keywords in RULES.items():
for keyword in keywords:
if keyword in shop_name:
return category
return None
科目ごとにキーワードのリストを持たせ、部分一致(in)で判定します。全キーワードを調べ終えてからNoneを返す点がポイントで、forの中で早々にreturn Noneしてしまうと、他の科目を確認する前に処理が終わってしまいます。
3. Gemini APIによるAI判定
ルールに当てはまらなかった行だけを、Gemini APIに判定してもらいます。AIに自由回答させると表記がゆれるため、選択肢を渡して1つだけ選ばせる形にしています。
import time
from google import genai
from google.genai.errors import ServerError
client = genai.Client()
MODEL = "gemini-3.6-flash"
def ai_classify(shop_name):
"""店名を受け取り、RULESのキーの中から1つ選んでもらい、その科目名を返す"""
categories = ",".join(RULES.keys())
prompt = f"""
次の店名は、どの勘定科目に分類されますか。
選択肢の中から、名前だけを1つ答えてください。他の言葉は書かないでください。
店名: {shop_name}
選択肢: {categories}
"""
for i in range(3):
try:
response = client.models.generate_content(
model=MODEL,
contents=prompt,
)
answer = response.text.strip()
if answer in RULES:
return answer
else:
return "要確認"
except ServerError:
print(f"混雑のため失敗。5秒待って再試行します({i + 1}回目)")
time.sleep(5)
return "要確認"
APIキーはコードに直接書かず、環境変数GEMINI_API_KEYから自動で読み込ませています。無料枠のAPIはサーバー混雑(503エラー)で失敗することがあるため、try/exceptとtime.sleepで、失敗時に数秒待って自動リトライする処理を入れています。
4. openpyxlでExcelに出力
import openpyxl
def build_data(path):
"""CSVを読み込み、ルール判定→AI判定までを行い、全件のデータを返す"""
data = load_expenses(path)
matched = []
unmatched = []
for row in data:
cla = classify(row["利用店名"])
row["勘定科目"] = cla
if cla:
matched.append(row)
else:
unmatched.append(row)
for row in unmatched:
row["勘定科目"] = ai_classify(row["利用店名"])
return matched + unmatched
def export_to_excel(all_data, filename="経費一覧.xlsx"):
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "経費一覧"
ws.append(["日付", "利用店名", "金額", "勘定科目"])
total = 0
for row in all_data:
ws.append([row["日付"], row["利用店名"], row["金額"], row["勘定科目"]])
total += row["金額"]
count = len(all_data)
ws.cell(count + 2, 2).value = "合計"
ws.cell(count + 2, 3).value = total
wb.save(filename)
辞書のままではws.append()が期待する形(列記号がキーの辞書)と一致しないため、必要な値を決まった順番でリストに組み立ててから渡しています。合計行の位置は「見出し1行 + データ件数 + 1」で計算していて、ここで一度、データの最終行に合計を上書きしてしまうオフバイワンのミスを経験しました。
5. customtkinterでのGUI化
処理をボタン1つで動かせるようにしました。AI応答待ちの間、GUIが固まらないようthreadingでメインの画面処理と重い処理を分けています。
import os
import threading
from tkinter import filedialog
import customtkinter as ctk
selected_path = None
def choose_file():
"""「CSVを選ぶ」ボタンが押されたときの処理"""
global selected_path
path = filedialog.askopenfilename(
title="CSVファイルを選んでください",
filetypes=[("csvファイル", "*.csv")],
)
if path:
selected_path = path
file_label.configure(text=os.path.basename(path))
def do_work():
"""別スレッドで実行する重い処理"""
all_data = build_data(selected_path)
export_to_excel(all_data)
app.after(0, update_done_label, len(all_data))
def update_done_label(count):
"""処理完了後、メインスレッドで実行される画面更新処理"""
status_label.configure(text=f"完了しました({count}件)")
run_button.configure(state="normal")
def run_process():
"""「実行」ボタンが押されたときの処理"""
if selected_path is None:
status_label.configure(text="先にCSVを選んでください")
return
status_label.configure(text="処理中...")
run_button.configure(state="disabled")
thread = threading.Thread(target=do_work, daemon=True)
thread.start()
app = ctk.CTk()
app.title("経費仕分けツール")
app.geometry("400x260")
choose_button = ctk.CTkButton(app, text="CSVを選ぶ", command=choose_file)
choose_button.pack(pady=10)
file_label = ctk.CTkLabel(app, text="ファイル未選択")
file_label.pack(pady=5)
run_button = ctk.CTkButton(app, text="実行", command=run_process)
run_button.pack(pady=20)
status_label = ctk.CTkLabel(app, text="")
status_label.pack(pady=10)
app.mainloop()
threading.Thread(target=do_work, daemon=True)で重い処理を別スレッドに切り出し、処理が終わったらapp.after(0, update_done_label, ...)でメインスレッドに画面更新を依頼する形にしています。別スレッドから直接GUI部品を操作するのは安全ではないため、必ずapp.after経由にしているのがポイントです。また、処理中に二重で「実行」を押されるとファイルの書き込みが競合するため、処理中はボタンをdisabledにしています。
つまずいた点
- Excelの行番号を1つずれて計算してしまい、データの最終行を合計の数式で上書きしてしまった(off-by-oneミス)
- 使用していたGeminiのモデル名が新規ユーザー向けに提供終了しており、404エラーで気づいた
- 仮想環境(venv)がVS Code上で正しく認識されず、別のPythonで実行してしまっていた
いずれも、エラーメッセージを1行ずつ読み解くことで解決できました。
今後の展望
- PyInstallerによるexe化で、配布できる形にする
- 誤って判定された内容を修正すると、ルール辞書に自動反映される仕組みの追加
- 対応するCSVの文字コード(Shift_JIS系)の自動判定
おわりに
ルールベースとAIを組み合わせることで、無料枠の範囲でも実用的な仕分けツールを作ることができました。同じようにAI×自動化の学習中の方の参考になれば幸いです。