Copy last month's quote, change the client name and the numbers, send it. That was my process for years of freelance site work. The evening it nearly caught up with me, I spotted a quote where everything was new except the total at the bottom, which still belonged to the previous job.
What unsettled me afterwards was that I could not reconstruct how I had caught it. My eye happened to land there. There is no reason it would land there next time.
That weekend I asked Antigravity for a small tool that turns a quote into a PDF. The first attempt was mostly thrown away.
Asking for Everything at Once Hid the Bug
My first prompt was greedy, reading it back now. Take the line items, calculate the amounts, lay them out with my logo, render a PDF, and save it with a dated filename — all of it in one message.
What came back did run. It ran, and the total on the PDF was one yen off from my calculator.
The trouble was that I could not trace where that one yen appeared. The arithmetic, the display formatting, and the PDF conversion all lived in the same file, so there was no way to see which step had rounded. When I told the agent the total was off, it added rounding somewhere else, the total matched, and the per-line subtotals stopped matching instead.
Looking back, my mistake was not that I asked for too much. It was that I asked for a chunk larger than anything I could verify. Had I cut it into pieces I could check one at a time, the drift would have announced its own location.
I've written about sizing requests from a different angle in measuring a request before you press send, but what I needed that evening was something plainer.
Four Requests: Data, Arithmetic, Layout, Filename
I split the rebuild into four prompts. The dividing line was not features — it was whether I could confirm each piece on its own.
| Order | What I asked for | How I checked it |
|---|---|---|
| 1 | Decide the JSON shape of a quote | Could I transcribe three real past quotes into it? |
| 2 | The amount calculation as a standalone function | Does it agree with my calculator? |
| 3 | An HTML template rendered to PDF | Does the print preview hold together? |
| 4 | Output folder and filename | Do two quotes on the same day overwrite each other? |
The first one is not code. It is writing down what actually appears on my quotes — item name, unit price, quantity, notes — before letting the agent invent fields. Transcribing three old quotes taught me something I had never noticed: every quote of mine carries a section listing what is not included, and that section is the longest one on the page.
That discovery changed the JSON shape. An exclusions field that I would have treated as an afterthought became a first-class array, which meant the template had room for it from the start. Had the agent designed the schema, it would have produced something reasonable and generic, and I would have spent the next three quotes bending my own paperwork to fit it.
Pulling the Arithmetic Out First
The second request covered calculation only. No formatting, no PDF.
# quote_calc.py — responsible for the arithmetic and nothing else
from decimal import Decimal, ROUND_DOWN
RATE = Decimal("0.10") # the multiplier, decided in exactly one place
def line_total(unit_price: str, quantity: int) -> Decimal:
"""One line's subtotal. Build Decimal from a string, never from a float."""
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": "Home page design", "unit_price": "120000", "quantity": 1},
{"name": "Interior page design", "unit_price": "45000", "quantity": 3},
]
print(summarize(items))
# {'subtotal': Decimal('255000'), 'surcharge': Decimal('25500'), 'total': Decimal('280500')}The one thing I spelled out in the prompt was that money must not be held as a float. Multiply 0.1 in binary floating point and the last digit starts drifting once the numbers get long. The first version used floats because I had said nothing about it. The numeric type is the caller's decision, not the agent's. Adding that single line to my prompts ended a repeated round of corrections.
Where the rounding happens is the second decision, and it is not cosmetic.
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") # rounding per line gives 33 x 3 = 99, one yen short
assert result["total"] == Decimal("1105")Asking for this test before touching the implementation means that when the agent moves rounding somewhere else, something turns red immediately. Which convention you pick matters less than picking one and writing it down; a client who adds the lines by hand will land on whichever answer your document implies, and the two of you should not be reading different totals off the same page. The reason I could not chase that first stray yen was simply that nothing was in place to turn red.
Let HTML Carry the Layout, Put PDF on Top
Only the third request touched appearance. I knew I would nudge rule positions and logo margins many times, so I asked for the layout in HTML and CSS, with PDF rendering as a thin layer above it. Keeping something I can open in a browser and adjust suits how I actually work.
# render.py — responsible for appearance and nothing else
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)The template settles paper size, margins, and typeface up front.
@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 on the amount column is there so digits line up vertically. With figures stacked in a true column, a wrong order of magnitude stands out on a glance. For someone who once nearly sent a quote carrying last month's total, that is a checking device rather than a style preference.
Two Places It Went Wrong
Japanese text came out blank. The first PDF showed the Latin characters and left every Japanese line empty. The cause was the typeface: the font named in my CSS was not installed in that environment. Ask the agent about it and it will start rewriting the CSS, but the fix belongs on the environment side. Listing what is installed takes one command — fc-list :lang=ja family on Linux and most containers, fc-list or the Font Book app on macOS — and putting one of those names first in the stack makes the text appear on the next run. It is worth doing this before the first render rather than after, because a blank column reads like a template bug and sends you looking in the wrong file. Whether characters turn into garbage or disappear entirely points at different causes — a distinction I ran into before with garbled Japanese in the built-in terminal.
A page break landed in the middle of the item table. On a longer quote the table split across pages, and the continuation page carried numbers with no column headers. Wrapping the header row in thead and giving it display: table-header-group repeats the headers on every page. This one is invisible in the browser and only shows up in the PDF, so I keep a sample quote with about thirty rows and render that before I build a real one.
Deciding the Filename
The last request was the shortest of the four.
from datetime import date
def output_name(client: str, issued: date, revision: int) -> str:
"""A name that will not overwrite an earlier version made the same day."""
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("Yamada Design Studio", date(2026, 9, 19), 2))
# 20260919_Yamada_Design_Studio_estimate_r2.pdfThe revision number is in there because a negotiation once left me unsure which figure I had actually sent. If nothing overwrites, the older file survives, and surviving files can be compared.
I keep the output folder outside the project directory as well. Early on I had the tool write into the repository, and a cleanup step removed a quote I still needed. Generated documents that a client has already seen are not build artifacts, even when a script produced them.
Splitting the work into four showed me the problem was never the agent's capability. The unit I was willing to call correct was simply too large. The single-shot version worked, in the sense that it ran. I just had no way to confirm it.
These days, before writing a request, I write one line first: what will I look at to decide whether the result is right? When I cannot write that line, the request is not yet small enough.
If you are moving similar paperwork off copy-and-edit, I'd start at step two — the calculation as its own function, with one test that agrees with your calculator. With that settled before the layout, every later adjustment costs far less.