What changed, and why
Ollama's llama.cpp server keeps a KV prefix cache: when a new request shares a byte-identical prefix with a cached one, that prefix skips prefill entirely. Our two highest-traffic prompts — enrich (propose a category) and interpret (read the owner's reply) — were built backwards for it: per-call facts first, then the candidate category list. The category list is the one block that never changes between calls (one budget, one list) and it is most of the prompt, yet the cache could never match past the first line.
This PR only reorders. Candidates now lead, extending the static system prompt into one long stable prefix; the per-call facts trail. Same words, same facts — nothing about what the model sees is added or removed.
Enrich: candidates first, facts last
The system prompt's description of the input order is updated to match (stale docs are bugs), and _format_request flips: the stable block opens the user message, payee/amount/memo/hint close it.
The reordered prompt; the docstring states the prefix-cache contract.
src/ynab_agent/agentic/enrich.py · 344 lines
⋯ 72 lines hidden (lines 1–72)
⋯ 235 lines hidden (lines 110–344)
Interpret: the reply goes last
Same flip, plus one deliberate placement: the reply — the most variable line of all — moves to the very end. That maximizes the shared prefix and also puts the text being classified nearest the generation point.
Candidates lead; the human's reply is the final line.
src/ynab_agent/agentic/interpret.py · 236 lines
⋯ 102 lines hidden (lines 1–102)
⋯ 115 lines hidden (lines 122–236)
The ordering contract, pinned
Two small tests assert the property the whole PR exists for: the candidate block precedes the per-call facts, and interpret's reply line is last. They fail on exactly the reordering regression, nothing else.
Enrich: candidates before payee; the hint stays the final line.
tests/agentic/test_enrich.py · 398 lines
⋯ 378 lines hidden (lines 1–378)
Interpret: candidates before payee; the reply ends the prompt.
tests/agentic/test_interpret.py · 192 lines
⋯ 184 lines hidden (lines 1–184)
Gate: 744 passed, 7 skipped; ruff, mypy, and the workflow-determinism check clean.