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?

LLMに金額を計算させない(家計簿の分類と節約案の作り方)

0
Last updated at Posted at 2026-10-06

はじめに

家計簿アプリで、Bedrock(Claude)に2つのことをさせています。明細のカテゴリ分けと、月末の節約案です。

どちらも、プロンプトに禁止事項を書き足すより、渡すデータの形を変えるほうが安定しました。この記事はその具体例です。

6本のシリーズの5本目です。

  1. カード・PayPay・Suica・レシートを1つにまとめる家計簿をサーバレスで作った
  2. 取込の検算が、実は何も検算していなかった話
  3. 再取込で1,300件のカテゴリが消えた話
  4. 一度踏んだ不具合を、検査にして置いておく
  5. LLMに金額を計算させない(この記事)
  6. 自作の家計簿の画面を、使いにくさの洗い出しから作り直した

分類はLLMを最後に置く

カテゴリ分けは4段階で、Bedrockは最後です。

分類の順番。条件つきルール、手修正の辞書、個人宛の送金、コードのルール、過去の判定と進み、最後に残ったものだけBedrockへ送る

判定結果は辞書に書き戻すので、同じ店が次に出てきたらBedrockを呼びません。運用を重ねるほど呼び出しが減ります。実際、1,400行のCSVを取り込み直したときに、辞書とルールだけで555件が確定し、Bedrockの呼び出しは0回でした。

個人宛の送金はBedrockに送りません。個人名は店名として意味を持たず、聞いても「その他」が返るだけです。支出か立て替えの返済かは人にしか判断できないので、要確認に回します。

カテゴリは固定リストから選ばせる

自由記述を許すと「食費」「食料品」「食品」が混ざって集計が崩れます。18個の固定リストから選ばせ、リストに無いものが返ったら採用しません。入力していない店名が返ってきた場合も同じです。

このうち3つは、Bedrockに選択肢として見せません。

# ルールでのみ付与し、Bedrockには選択肢として見せないカテゴリ。
# 特定のブランドを独立させたものは、名前から連想して他店にも
# 広く当てられてしまう(スタバを見せると他のカフェまでスタバになる)。
RULE_ONLY_CATEGORIES = ["カフェ", "通勤費", "高速代"]

「カフェ」はスターバックスなど特定のブランドだけに付けたいカテゴリでした。選択肢に出すと、ドトールもタリーズも「カフェ」になります。判定が決まりきったものは、そもそも聞く必要がありません。

呼び出しをまとめる

分類はDynamoDB Streamsで起動します。バッチサイズ100、待ち時間1分です。

1件ずつ起動すると明細1件につき1リクエストになります。1分ためると、1回の起動で最大100件を1リクエストにまとめられます。

辞書の初期構築を試算したときの数字です。

明細116件 → ユニーク店名45件(ルール10件 / Bedrock35件・2バッチ・約0.3円)

節約案は、計算を全部こちら側で終わらせる

月末の節約案は、Bedrockに文章を書かせています。ただし数字は一切計算させません。合計・前月差・平均・ばらつき・割合・倍率を全部こちらで確定させてから渡します。

facts = {
    "対象月": latest["month"],
    "対象月の合計": latest["net"],
    "対象月のポイント還元": latest.get("points", 0),
    "還元を引いた実質": latest["net"] - latest.get("points", 0),
    "前月": prev["month"] if prev else None,
    "前月の合計": prev["net"] if prev else None,
    "月ごとの合計": {m["month"]: m["net"] for m in closed},
    "削減の余地があるカテゴリ": discretionary,
    "毎月ほぼ一定のカテゴリ": fixed,
    "相手のある支出のカテゴリ": relational,
    "繰り返し利用した支出先": repeated,
    "1回だけの支出先": one_off,
}

以下、作り直すことになった4つの点です。どれも最初は出力を見て気づきました。

1. カテゴリを3つに分けて渡す

「節約できるところを挙げて」と頼むと、家賃や通信費が候補に出ます。言われても減らせません。

そこで、対象月のカテゴリを3つに分けてから渡しています。

区分 判定 扱い
毎月ほぼ一定 変動係数 < 0.15 かつ8割以上の月に出現 家賃・通信費など。削減を勧めない
相手のある支出 全額が個人宛送金 割り勘や立替の返済。自分だけでは減らせない
削減の余地がある 上記以外 指摘の対象

変動係数(標準偏差÷平均)のしきい値は、実データを見て決めました。

# 実データでは固定費0.01・サブスク0.00に対し、コンビニ0.28・外食0.34。
# 真の固定費は桁違いに小さいため、0.35では外食まで固定扱いになり
# 削減対象から消えてしまう。
FIXED_COST_VOLATILITY = 0.15

固定費とサブスクはほぼ0、コンビニと外食は0.3前後と、はっきり分かれていました。0.35にすると外食まで「動かせない支出」になり、指摘対象から消えます。

2. 割合と倍率もこちらで計算して渡す

「計算しないでください」と書いても、全体に占める割合や平均との倍率は、モデルが自然に割り算して書いてしまいます。

禁止するより、正しい値を与えるほうが確実でした。

entry = {
    "カテゴリ": k, "対象月": amount, "平均": avg,
    "平均との差": amount - avg,
    "前月との差": deltas.get(k, 0),
    "支出全体に占める割合": f"{amount / total * 100:.1f}%",
    "平均に対する倍率": (f"{amount / avg:.2f}倍" if avg > 0 else None),
}

対話で質問できるタブでも同じことが起きました。「上位2件だけで45%」という文が出て、確かめたらモデルが自分で足して割った数字でした。そこで、上から数えた累計をあらかじめ渡すようにしました。

if total:
    e["この合計に占める割合"] = f"{v['amount'] / total * 100:.1f}%"
    # 「上位2件だけで45%」はモデルが自分で足して割った数字だった。
    # 上から数えた累計を先に渡しておけば、引用で済む
    e["ここまでの累計"] = running
    e["ここまでの累計の割合"] = f"{running / total * 100:.1f}%"

3. 支出先には種別を付ける

金額と回数だけを渡すと、個人宛の送金を飲食店と取り違えます。実際に、個人名の送金先を「1回1万円の店舗利用」として扱う出力が出ました。

渡す項目に種別を足しました。

e = {
    "支出先": name,
    "金額": v["amount"],
    "回数": v["count"],
    "1回あたり": round(v["amount"] / v["count"]) if v["count"] else 0,
    "種別": "個人宛送金" if v.get("entityType") == "person" else "店舗",
}

4. 繰り返しと単発を別の表にする

支出先を1つの表にまとめて渡すと、1回しか使っていない店に「頻度を月1回に減らしましょう」という助言が出ます。これも実際に出ました。

2つの表に分けました。

  • 繰り返し利用した支出先(2回以上)
  • 1回だけの支出先

分けたうえで、プロンプトでは話の向け方を変えるよう指示しています。繰り返しなら回数の話ができますが、1回だけの支出に「月1回に抑える」は成立しません。金額そのものの妥当性に触れてもらいます。

構造で示すほうが、文章で禁止するより通ります。

進行中の月は渡さない

月の途中では、その月の支出は月末まで積み上がりません。前月と比べると必ず「大幅に減少した」という読みになります。

そこで、節約案の対象は直近の締まった月にしています。進行中の月はモデルに渡しません。

def advice_target(months):
    """助言の対象は直近の締まった月。進行中の月は評価しない"""
    closed = [m for m in months if not m["partial"]]
    return closed[-1] if closed else None

画面のグラフには斜線で表示しますが、平均・前月差・助言の対象からは除きます。

この判定で一度つまずきました。サーバ側がUTCで「今月」を決めていたため、日本時間の毎月1日 00:00〜09:00 のあいだ、始まったばかりの月が「締まった月」と判定されていました。月に1回、9時間だけ節約案の対象が狂います。日付の区切りを決める箇所を日本時間に統一して直しました。

相談タブは帳簿を書き換えない

自然言語で質問できるタブがあり、Bedrock の tool use で動いています。道具は5つで、返すのは集計済みの数字だけです。

1つだけ、カテゴリの変更を頼まれたときに使う道具があります。この道具は帳簿を書き換えません。対象を絞って「提案」を返すだけで、画面に確認が出て、本人が押して初めて反映されます。

state["proposal"] = {
    "newCategory": new_category,
    "items": [...],
}
return {
    "提案した件数": len(rows),
    "備考": "まだ反映していません。画面で本人が押すと反映されます",
}

反映は既存の PATCH を通します。そちらには加盟店辞書への書き戻しと、手修正であることの記録が入っているためです。迂回すると、再取込のときに修正が消えます(3本目の記事に書いた問題です)。

対象を絞らない変更も受け付けません。店名かカテゴリのどちらかで絞ることを必須にしています。

if not (args.get("keyword") or args.get("category")):
    return {"エラー": "店名かカテゴリのどちらかで対象を絞ってください。"
                   "月の全明細をまとめて変えることはできません"}

API Gateway の29秒に収める

相談タブは tool use で何往復かするため、時間が読めません。API Gateway は29秒で切るので、往復の回数と経過時間の両方で打ち切ります。

MAX_ROUNDS = 6
DEADLINE = 20.0   # 最後の一往復ぶんを残す

for _ in range(MAX_ROUNDS):
    out_of_time = time.monotonic() - started > DEADLINE
    ...
    # 時間が尽きたら道具を取り上げる。ここで渡し続けると、
    # 応答を返せないまま API Gateway に切られる
    if not out_of_time:
        kwargs["toolConfig"] = _tool_config()

道具を外せば、モデルはその場で答えを書きます。何も返らないよりは良い結果になります。

boto3 の再試行も切っています。既定では数回まで再試行しますが、29秒で切られるこの関数では、再試行に入った時点で間に合いません。

bedrock = boto3.client("bedrock-runtime", config=Config(
    retries={"max_attempts": 1, "mode": "standard"},
    read_timeout=15, connect_timeout=3,
))

出力の記号を落とす

画面は答えをそのまま文字として出します(HTMLとして解釈しません)。モデルが **強調** を書くと、記号がそのまま見えます。

プロンプトで「記号は使わないでください」と指示していますが、時々混ざります。最後に落とすようにしました。

_EMPHASIS = re.compile(r"\*\*(.+?)\*\*|__(.+?)__", re.S)
# \s だと改行まで食べ、段落の空行が詰まる。行内の空白だけを対象にする
_HEADING = re.compile(r"^[ \t]*#{1,6}[ \t]*", re.M)

見出しの記号を消すとき、\s を使うと改行まで消えて段落が詰まります。行内の空白だけを対象にしています。

まとめ

4つとも、プロンプトに書き足して直そうとして、うまくいかなかったものです。

出た問題 直し方
家賃を削れと言う カテゴリを3つに分けて渡す
自分で割り算して数字を作る 割合と倍率を計算して渡す
個人宛送金を飲食店と混同する 支出先に種別を付ける
1回だけの店に「頻度を減らせ」と言う 繰り返しと単発を別の表にする

指示は読み飛ばされることがありますが、渡していないデータは使えません。禁止したいことがあるときは、まず渡し方を変えられないか見るようにしています。

次の記事

6本目では画面の話を書きます。使いにくさを洗い出して操作の入口をまとめたこと、「元に戻す」が画面だけでは作れなかったこと、AIが作ったように見える見た目を作り直したことです。

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?