はじめに
家計簿アプリで、Bedrock(Claude)に2つのことをさせています。明細のカテゴリ分けと、月末の節約案です。
どちらも、プロンプトに禁止事項を書き足すより、渡すデータの形を変えるほうが安定しました。この記事はその具体例です。
6本のシリーズの5本目です。
- カード・PayPay・Suica・レシートを1つにまとめる家計簿をサーバレスで作った
- 取込の検算が、実は何も検算していなかった話
- 再取込で1,300件のカテゴリが消えた話
- 一度踏んだ不具合を、検査にして置いておく
- LLMに金額を計算させない(この記事)
- 自作の家計簿の画面を、使いにくさの洗い出しから作り直した
分類はLLMを最後に置く
カテゴリ分けは4段階で、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が作ったように見える見た目を作り直したことです。
