前回の見積書をコピーして、宛名と金額を書き換えて送る——受託のサイト制作を引き受けるようになってから、私はずっとその作り方をしておりました。危なかったのは、コピー元の合計金額だけが前回のまま残っていた見積書を、送信の直前で見つけた夕方です。宛名も日付も品目も新しいのに、いちばん下の数字だけが古いままでした。
手が止まったのは、そのミスを自分がどうやって見つけたのか思い出せなかったからです。たまたま目が行っただけでした。次に同じ位置に目が行く保証はどこにもありません。
その週末、Antigravity に「見積書を PDF で出す小さなツールを作ってほしい」と頼みました。そして最初の一回は、ほぼ作り直しになりました。
まとめて頼んだ結果、どこが違うのか分からなくなりました
最初の依頼文は、いま読むと欲張りすぎておりました。品目を入力したら金額を計算して、会社のロゴを入れたレイアウトで組んで、PDF にして、日付入りのファイル名で保存するところまで——それを一度に書いて渡しました。
返ってきたものは、確かに動きました。動いたうえで、出てきた PDF の合計が私の電卓と 1 円ずれておりました。
困ったのは、その 1 円がどこで生まれたのか追えなかったことです。金額の計算と、表示用の書式と、PDF に流し込むときの変換が一つのファイルに同居していて、どの段で丸められたのか読み取れません。エージェントに「合計が 1 円ずれています」と伝えると、今度は別の場所に丸めを足してきて、合計は合ったのに明細の小計が合わなくなりました。
いま思えば、私は一度に渡しすぎたのではなく、確かめられる単位で渡していなかったのだと思います。一つずつ目で確かめられる大きさに切っていれば、ずれた瞬間に場所が分かったはずでした。
依頼の大きさそのものを見積もる話は エージェントへの依頼は、送信ボタンを押す前に大きさを測れる で別の角度から扱っておりますが、この日の私に必要だったのは、もっと素朴な線引きでした。
データ・計算・見た目・保存の 4 つに割る
書き直すにあたって、依頼を 4 回に分けました。分け方の基準は機能ではなく、それ単体で正しさを確かめられるかどうかです。
| 順番 | 頼むこと | 確かめ方 |
|---|---|---|
| 1 | 見積の中身を JSON の形に決める | 自分の過去の見積を 3 件書き写せるか |
| 2 | 金額の計算だけを関数にする | 手計算と突き合わせられるか |
| 3 | HTML のひな形に流して PDF にする | 印刷プレビューで崩れていないか |
| 4 | 保存先とファイル名を決める | 同じ日に 2 通作っても上書きされないか |
1 つ目はコードではありません。自分の見積書に何が載っているのかを、項目名・単価・数量・備考という粒度で書き出すだけの作業です。ここを飛ばしてエージェントに考えさせると、私の運用に無い欄が増えて、あとから削ることになります。過去 3 件を書き写してみると、私の見積には「作業に含まれないもの」を書く欄が必ずあり、そこがいちばん長いと分かりました。
この発見で JSON の形が変わりました。後回しにしていたはずの欄を配列として最初から持たせたので、ひな形の側にも置き場所ができます。先に形をエージェントへ任せていたら、きれいで一般的な形が返ってきて、私のほうが次の 3 件でそれに合わせていたはずです。
金額の計算だけを先に切り出す
2 つ目の依頼は、計算だけを受け持つファイルを作ってもらうことでした。表示も PDF も含めません。
# quote_calc.py — 金額の計算だけを受け持ちます
from decimal import Decimal, ROUND_DOWN
RATE = Decimal("0.10") # 掛け率。運用に合わせてここ 1 か所だけで決めます
def line_total(unit_price: str, quantity: int) -> Decimal:
"""1 行ぶんの小計です。float ではなく文字列から Decimal を作ります。"""
return Decimal(unit_price) * quantity
def summarize(items: list) -> dict:
subtotal = sum((line_total(i["unit_price"], i["quantity"]) for i in items), Decimal("0"))
surcharge = (subtotal * RATE).quantize(Decimal("1"), rounding=ROUND_DOWN)
return {"subtotal": subtotal, "surcharge": surcharge, "total": subtotal + surcharge}
if __name__ == "__main__":
items = [
{"name": "トップページ デザイン", "unit_price": "120000", "quantity": 1},
{"name": "下層ページ デザイン", "unit_price": "45000", "quantity": 3},
]
print(summarize(items))
# {'subtotal': Decimal('255000'), 'surcharge': Decimal('25500'), 'total': Decimal('280500')}金額を float で持たないところだけは、依頼文にはっきり書きました。0.1 を浮動小数点で扱うと、桁が増えたときに一の位が揺れます。最初の版でエージェントが素直に float を使っていたのは、私が何も言わなかったからです。数字の型は、頼む側が決めて渡します。 その一行を依頼文に足してから、同じ手直しをしなくなりました。
丸める位置も、計算の段で一度だけと決めました。行ごとに丸めるか、小計に対して一度だけ丸めるかで、合計は実際にずれます。
from decimal import Decimal
from quote_calc import summarize
def test_surcharge_is_rounded_once_on_the_subtotal():
items = [
{"name": "A", "unit_price": "335", "quantity": 1},
{"name": "B", "unit_price": "335", "quantity": 1},
{"name": "C", "unit_price": "335", "quantity": 1},
]
result = summarize(items)
assert result["subtotal"] == Decimal("1005")
assert result["surcharge"] == Decimal("100") # 行ごとに丸めると 33 x 3 = 99 で 1 円ずれます
assert result["total"] == Decimal("1105")このテストを先に書いてもらってから本体を直すと、エージェントが丸めを別の場所へ足したときに、その場で赤くなります。最初の版で私が 1 円を追えなかったのは、赤くなる場所を用意していなかったからでした。
見た目は HTML に任せ、PDF はあとから被せる
3 つ目の依頼で初めて見た目の話に入ります。私は罫線の位置やロゴの余白を何度も直すと分かっていたので、レイアウトは HTML と CSS で組んでもらい、PDF 化はその上に薄く被せる形にしました。ブラウザで開いて直せる状態が長く残るほうが、私の直し方に合っておりました。
# render.py — 見た目だけを受け持ちます
from pathlib import Path
from weasyprint import HTML
TEMPLATE = Path("template.html").read_text(encoding="utf-8")
def render_pdf(context: dict, out_path: Path) -> None:
html = TEMPLATE
for key, value in context.items():
html = html.replace("{{" + key + "}}", str(value))
HTML(string=html, base_url=".").write_pdf(out_path)ひな形の側では、用紙と余白と書体を先に決めておきます。
@page { size: A4; margin: 18mm; }
body { font-family: "Noto Sans JP", "Hiragino Sans", "Yu Gothic", sans-serif; }
.amount { font-variant-numeric: tabular-nums; }数字の欄に tabular-nums を入れているのは、桁の位置を揃えるためです。等幅の数字にしておくと、金額の列が上下でぴたりと重なり、読み返したときに桁の違いに気づけます。前回の金額が残っていた見積書を見落としかけた身としては、ここは見た目の好みではなく確認のための仕掛けです。
つまずいた 2 か所
日本語が真っ白になりました。 最初に出力した PDF は、英数字だけが並んで日本語の行が空白でした。原因は書体で、CSS に書いた名前の書体がその環境に入っていなかっただけです。エージェントに聞くと CSS の書き方を直そうとしてきますが、直す場所は環境の側でした。手元に入っている書体名を調べるのは一行で済みます。Linux やコンテナなら fc-list :lang=ja family、macOS なら fc-list か Font Book です。そこに出てきた名前を CSS の先頭に置くと一度で出ます。出力する前に確かめておくほうがよいのは、真っ白な列がひな形の不具合のように見えて、見当違いのファイルを開いてしまうからです。文字が化けるのか消えるのかで原因の当たりが変わる点は、Antigravity 内蔵ターミナルで日本語が文字化けするときの原因と対処 と似ています。
改ページが明細の途中で入りました。 品目が増えた見積で、表の途中に改ページが挟まり、続きのページに見出しが無いまま数字だけが並びました。thead を使って display: table-header-group を指定すると、ページをまたいでも見出しが繰り返されます。これは PDF にして初めて見える崩れで、ブラウザの画面では最後まで気づけませんでした。品目を 30 行ほど並べた見本を 1 つ作って、毎回それで出力してから本番を作るようにしております。
保存先とファイル名を決めておく
最後の依頼が、いちばん短く済みました。
from datetime import date
def output_name(client: str, issued: date, revision: int) -> str:
"""同じ日に作り直しても上書きされない名前にします。"""
safe = "".join(c if c.isalnum() else "_" for c in client).strip("_")
return f"{issued:%Y%m%d}_{safe}_estimate_r{revision}.pdf"
if __name__ == "__main__":
print(output_name("山田デザイン事務所", date(2026, 9, 19), 2))
# 20260919_山田デザイン事務所_estimate_r2.pdf改訂番号を名前に入れたのは、先方とのやり取りで金額が動いたときに、どちらを送ったのか後から分からなくなった経験があるからです。上書きしない決まりにしておけば、古いほうが残ります。残っていれば見比べられます。
保存先をプロジェクトの外に置いているのも、同じ理由です。最初はリポジトリの中に書き出しておりましたが、片づけの手順で必要な 1 通を消してしまいました。先方が一度目にした書類は、スクリプトが作ったものであっても成果物とは別の扱いにしております。
4 つに割ってみて分かったのは、エージェントの能力の問題ではなかったということです。私が「これで合っている」と言える単位が大きすぎただけでした。1 回目の依頼で出てきたものも、動くという意味では出来ておりました。ただ、私には確かめようがなかったのです。
いま私は、依頼を書く前に「これが返ってきたとき、何を見て合否を決めるか」を一行だけ先に書くようにしております。その一行が書けない依頼は、まだ割り足りません。
もし同じ手作業を移そうとされているなら、2 つ目——金額の計算だけを関数にして、手計算と合う 1 件のテストを書くところ——から始めてみていただければと思います。見た目より先にそこが固まっていると、あとの直しがずいぶん楽になります。
最後までお読みいただき、ありがとうございました。