Deterministic budgets, LLM narrative only
Budget-first travel planner · numbers in code, prose from the model
# summary
> A budget-first travel planner where every number is computed in code and the model writes only the prose.
# the problem
> Farovio answers one question: given this much money, this many people and this many nights, where can you actually go? The inputs come from several external sources on different schedules — flight prices, lodging and bus medians, FX rates, points of interest — and none of them agree on freshness or currency. The tempting build is one large model call that reads the form and writes the whole answer, prices included; that answer is fluent and sometimes wrong, and a wrong price is not a smaller version of a right one.
# what we built
> Python jobs ingest each source into a layered PostgreSQL schema: curated destination rows, cache tables that all carry source and fetched_at, and a derived cache of generated itineraries. The web app reads that schema over raw postgres.js tagged templates — plain SQL, no ORM, row types declared by hand. The budget engine is pure TypeScript with no framework or database imports: it takes rows as arguments and returns tiered packages, with all arithmetic in bigint minor units. The itinerary step is separate and receives only approved points of interest, returning day-by-day narrative under a JSON schema. Everything the reader sees as a number came out of the engine; everything they read as prose came out of the model.

# technical decisions
Money and eligibility are computed in code. The model is confined to narrative.
The system prompt forbids the model from writing any number at all — no price, distance, duration or percentage. Totals, remaining budget, fuel, currency conversion and visa eligibility are deterministic functions in lib/budget, lib/fx and lib/eligibility. FX rates arrive from Postgres as numeric(18,8) strings and are parsed into 1e8-scaled bigints, so a converted amount never passes through a float.
The honest trade-off: you give up fluency exactly where a number would sit naturally. The narrative cannot say "about two hours by bus" or refer to the price of the day it is describing, and the copy is stiffer for it. What you get back is arithmetic that is reproducible, diffable and testable with table fixtures, with no model in the loop. A slightly stiff sentence is a cosmetic defect. A confidently invented price is a different holiday.
Hallucination is handled as a validation failure, not a prompting problem.
The model only ever sees approved POI ids. Its output is parsed against a JSON schema, then every id in the plan is checked against the set that was offered. An id that was not in the list triggers one corrective turn that names the offending ids, and if the second attempt still fails, the request errors out rather than trying again.
The retry is bounded because each attempt costs money and wall-clock time on a route with a 60-second ceiling, and because a model that fails the id check twice is not going to pass on the third.
The spend guards sit after the cache lookup and before the paid call.
The generator is a billable Anthropic call reachable by anonymous users through three entry points, two of which are GETs — an SSR plan page and a public share page, both trivially hammerable by a crawler. It is fronted by a per-IP sliding window, a hard daily cap that reserves a slot before generating, and a bounded concurrency queue. The placement is the part that matters: the guards run after the cache is checked, so a cache hit consumes no quota and someone reloading their own plan is never penalised — only genuine billable generations count against the limits.
The daily reservation is released only when no call was made, and deliberately not refunded when generation throws, because tokens may already have been spent. The IP window is best-effort by design, since the client address comes from a header the app cannot fully trust. The hard stop is the daily cap, which short-circuits before the API client is ever constructed.
Scheduled jobs distinguish an operation failing from an item being absent.
The rule was written after a job was found reporting success while doing nothing. An operation that failed — 5xx, timeout, database or mail error — exits non-zero even if it was one item out of many. A source that answered healthily but had no row for that item, such as an FX provider that does not carry a given pair, writes a warning to stderr and exits zero.
A run that produced nothing at all exits non-zero. The test is not "was this partial" but "did an operation fail", because treating a genuine data gap as a failure fires a false alarm on every run, and a rule that cries wolf is ignored within weeks. Exit codes are only half of it. The old crontab line piped every byte into a log file, and cron only mails when a job writes output, so the exit code reached nobody.
The replacement is a wrapper that stays silent on success and writes a marked failure block to stderr, proven by forcing a real cron failure that actually delivered mail. The crontab line has since been swapped: the live entry runs the weekly job through that wrapper.
# stack
├─ Next.js 15 (App Router), TypeScript in strict mode ├─ PostgreSQL 16, append-only plain SQL migrations ├─ postgres.js tagged templates, no ORM ├─ zod on every API input ├─ Python 3.11 ingestion jobs (httpx, psycopg, typer) ├─ Anthropic API for narrative only, JSON-schema structured output ├─ vitest └─ Node behind Caddy and a Cloudflare tunnel
# outcome
> Live at farovio.com and usable without an account: enter budget, party and nights, get priced packages and a generated day-by-day plan.
> The spend guard was verified in production rather than in tests. Setting the daily cap to zero made the endpoint return 429 with a Retry-After header in 0.317s, against roughly 45 seconds for a real generation — proof that the short-circuit happens before the API call rather than after it. The cap was restored afterwards.
> An independent security review read every one of the roughly 40 query sites in the data layer. All of them are genuine tagged templates with interpolation in value position only: no identifier interpolation, no dynamic WHERE or ORDER BY assembly, and no occurrence of sql.unsafe anywhere in the repository.