A cheapest-day fare calendar for travel agents: Gemma 4 reads the supplier APIs, Chronos fills the gaps

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle

What I Built

I build software for a travel agency that runs a B2B flight booking portal: sub-agents log in, search, and book across 13 suppliers (a GDS, low-cost carriers, NDC airlines and fixed-departure consolidators).

Watch an agent with a customer on the phone and you’ll see the same thing every time:

“Is Tuesday cheaper than Wednesday?” search. “What about Thursday?” search.

Every search fans out to every supplier. It’s slow for the customer, and it burns supplier quota: GDS look-to-book ratios are a real cost.

Fare Calendar answers “which day is cheapest?” before the agent searches. It’s a date picker that shows the lowest fare under every date:

  • ● seen: a real fare an agent got for that date in the last 3 days
  • est.: a model estimate, with a likely range, for dates nobody searched recently

It’s built entirely from the portal’s own server logs, so it reflects the fares our suppliers actually return to our agents, not a public metasearch.

Who pays: B2B travel agencies and booking platforms. It plugs into an existing portal as a <fare-calendar> web component, reads logs the platform already writes, and runs on the agency’s own hardware.

Demo

  • Public demo (synthetic data): https://fare-calendar-demo.onrender.com
  • Production deploy: behind a sign-in page for our agents. It serves real forecasts for 557 routes and 23,626 priced dates from 114k observed fares.

Fare calendar: a fare profile strip above the month, the lowest fare under every date, and a panel explaining the picked day

  • The strip above the month is a fare profile, one bar per day. The cheap troughs are visible before you read a single number.
  • Each day shows its lowest fare. Seen fares get a dot, estimates a dotted underline, and the cheapest day is marked in magenta.
  • Picking a day explains it: “₹6,210, likely ₹5,340 to ₹7,270, estimated from nearby dates because nobody searched this day recently.”
  • It works with the keyboard, in dark mode and on a phone, and everything is labelled indicative. Agents reprice live before booking.

Code

https://github.com/adi-ray/fare-calendar

Python (FastAPI, pandas), a dependency-free web component, and 60 tests. Real logs, fares and private supplier mappings are git-ignored. The repo ships only synthetic data and the public Amadeus mapping.

How I Built It

Two open-weight models do two very different jobs.

1. Gemma 4 reads the suppliers’ APIs

The fares are buried in 13 different response formats: SOAP XML from a GDS, Navitaire XML, IATA NDC XML, and assorted JSON. Hand-writing 13 parsers before a deadline wasn’t going to happen.

So I wrote a tiny mapping language that works the same over JSON and XML, and had Gemma 4 write the mappings:

{
  "rows": "faresAvailable{*}",
  "fields": {
    "fare": "fares[*].passengerFares[*].fareAmount",
    "origin": "meta.origin",
    "destination": "meta.destination",
    "departure_date": "meta.departure_date"
  },
  "generated_by": "gemma4 (validated on 6/6 responses)"
}

Gemma sees a shrunken sample of one response. Code, not Gemma, decides whether a mapping is good. It applies the mapping to 6 real responses and rejects it unless:

  • most responses produce rows
  • fares look like per-adult totals, not IDs or base fares
  • the route the agent actually searched shows up

Failures go back to Gemma as specific feedback:

'fareComponentList' does not exist under …originDestinationOptionList[*]; keys there: ['fareComponentGroupList']. did you mean 'fareComponentGroupList'? 'fareComponentList' exists further down at 'fareComponentGroupList.fareComponentList'

Gemma never touches a fare at run time. Mappings are small JSON files a human can review.

The results: Gemma (local gemma4, 7.5B Q4, via Ollama on a 4 GB laptop GPU) mapped 7 suppliers in 1–6 attempts each. Three, IndiGo, SpiceJet and an NDC carrier, didn’t converge in 11 attempts, so I hand-wrote those and labelled them as hand-written. IndiGo and SpiceJet store each fare as separate charges, with discounts as positive amounts, which needed sum:charges[ChargeType!=Discount] − sum:charges[ChargeType=Discount].

What I learned getting a small model to do this reliably:

  • Turn thinking off. Gemma 4’s thinking mode is on by default in Ollama. On my GPU it turned a 5-second answer into a 58-second one, and the answer was worse.
  • Watch the context window. Ollama’s default context silently dropped the start of long prompts, which is where my instructions were. Every early failure looked like “Gemma ignored the spec”.
  • Make feedback name the fix. “Check your path” got repeated mistakes. “Use ^^.segment.from” got fixed mappings.
  • Bend the language toward the model. Gemma kept writing field paths from the document root, so the extractor now resolves those to the row’s own ancestor.

2. Chronos-Bolt fills the empty dates

Agents search a handful of dates. To fill the rest:

  1. A lead-time model separates “which day you fly” from “how far ahead you searched”, since fares climb as departure nears.
  2. Chronos-Bolt base (open weights, zero-shot, CPU) forecasts each unsearched stretch of the calendar from the departure dates before it. Missing days are passed as gaps, not invented. Its forecast is averaged with a same-weekday baseline: the two make different mistakes, and the average beats both.
  3. The estimate is re-priced for today’s lead time and blended with older searches for that exact date.

3. Data hygiene was half the work

  • Placeholders. One consolidator lists some dates at ₹100 (seats on request). In a lowest-fare calendar that wins every day, and it took my backtest error to 308%. Fares under ₹500, and anything outside 0.25×–4× the route median, are now dropped and counted.
  • Per-leg pricing. Some carriers price connecting itineraries per leg, so a DEL→HYD→BOM search could pose as a cheap “DEL-HYD fare”. Only the searched route is kept.
  • Group totals. Some suppliers price “2 adults” as one total, so only single-adult searches are used (85% of traffic).

4. Serving it on Render

Chronos runs where the logs live and writes a 565 KB forecasts.json. The API on Render is FastAPI plus the standard library, with no PyTorch. Two services come from one blueprint:

  • Public demo: synthetic data, free plan.
  • Private: Starter with a disk (paid with the Render credit). Agents sign in on a sign-in page with a signed session cookie, and the portal can embed it with an API key. It takes nightly uploads, and a Secret File fallback serves whichever forecast is newer.

Results (honest version)

The backtest uses 14 cutoff days (Oct 1–14). The calendar is built only from earlier logs and compared with the lowest fare agents actually got over the next two days, across 11,544 searched dates:

seen fares only weekday baseline Chronos-Bolt base + baseline
searched dates with a fare shown 36% 41% 41%
error when showing a real recent fare 1.0% 1.0% 1.0%
error on estimated dates (591) n/a 18.7% 16.2%

How I picked the forecaster (5 cutoffs, 498 estimated dates):

forecaster error on estimated dates
weekday baseline 18.4%
Chronos-Bolt small, alone 20.1%
Chronos-Bolt base, alone 17.6%
Chronos-Bolt small + baseline 17.9%
Chronos-Bolt base + baseline 15.1%

The calendar’s value is mostly coverage. It puts a number on 41% of the dates agents go on to search, up from 36%, and the “seen” fares are accurate.

Estimates are rough but measurably better than the obvious baseline. My first deploy used Chronos-Bolt small, which did worse than “same weekday, last few weeks”. The bigger base model, averaged with that baseline, cuts estimate error by about 13% over 14 cutoffs. One honest caveat: on a separate 5-day subset with only 63 estimated dates, the ensemble came out slightly worse (20.3% vs 19.0%). It’s one month of noisy, mixed GDS and fixed-departure history, and the backtest re-runs as logs accumulate.

Why Does Open Innovation Matter?

  • The data can’t leave. Supplier fares are negotiated rates, and supplier responses are commercially confidential. Pasting them into a hosted LLM to “help write a parser” is a non-starter. Gemma 4 runs locally, so every supplier response it read stayed on the laptop.
  • It costs nothing per use. Mapping a supplier is a one-off job on a 4 GB laptop GPU. Forecasts for 557 routes take a few minutes on a CPU. The deployed API needs no GPU and no AI vendor.
  • I could change how the model behaves. Turning thinking off, setting the context size and forcing JSON output are all switches I control. A closed API would have left me guessing why answers degraded.
  • Swapping models was a one-line change. When Chronos-Bolt small lost to the baseline, I tried a 4× bigger open model the same afternoon, backtested five variants on real data, and shipped the winner at no cost. No vendor pricing page, no waiting list.
  • No lock-in. The mappings are plain JSON, the forecaster is configurable (FARECAL_FORECASTER, FARECAL_MODEL_ID), and Gemma can be replaced by any OpenAI-compatible local server.
  • Open models plus deterministic checks is the pattern that worked. The model proposes, code verifies against real data. That’s what made a 7.5B model trustworthy enough to touch production data.

My Agent Session

This is a curated version of the build session with Claude Code: the turning points, including the wrong turns. Local paths, the private backend’s internals and real per-route fares are removed.



Fare Calendar: Gemma 4 reads the supplier APIs, Chronos fills the gaps (curated build session)

You

Week 2 theme is Side Hustle. I’m thinking of a fare-price calendar for our B2B flight booking app, so agents can see the estimated lowest fare at the time of choosing dates.

Agent

Good fit: travel agencies pay for this, and your own agents are the first users. Proposed core: an open-weight time-series model (Chronos-Bolt, Apache-2.0, CPU) learning from the agency’s own fare history, so search data never goes to a third-party API. Three questions before building: where does fare history come from, which model, and API + embeddable widget or inside the existing app?

You

Our search/booking logs. Chronos-Bolt. Standalone API + embeddable widget.

Agent

Built the skeleton against a documented CSV schema with clearly labelled SYNTHETIC logs: a lead-time fixed-effects model (fares climb as departure nears), a Chronos-Bolt forecaster with a seasonal-naive baseline, a FastAPI service and a <fare-calendar> web component. Synthetic backtest: Chronos 8.5% vs baseline 10.5% MAPE.

▸
Bash
python scripts/backtest.py –data data/sample_search_logs.SYNTHETIC.csv

$ python scripts/backtest.py --data data/sample_search_logs.SYNTHETIC.csv
overall: seasonal-naive 10.5% MAPE | chronos-bolt-small 8.5% MAPE
You

Here are the real server logs. Also: can we enter Best Use of Gemma?

Agent

The logs aren’t a CSV: they’re per-request folders, one per supplier call. 2,250 searches over 30 days, fanning out to 13 suppliers in different formats (GDS SOAP XML, Navitaire XML, IATA NDC XML, assorted JSON). Hand-writing 13 parsers isn’t realistic before the deadline, which gives Gemma a genuine job: read a sample of each supplier’s response and write the extraction mapping, run locally so negotiated supplier fares never leave the machine. Code validates every mapping against real responses.

▸
Bash
count RS files per supplier (names only)

$ count RS files per supplier (names only)
amadeus 824, oms 1798, scoot 792, starair 774, cheapfixdeparture 688, batikair 586, indigo 579, airiq 566, aix 410, spicejet 419, emirates 251, aeroprime 94, kandhari 15
Agent

Wrote a small mapping language that works the same over JSON and XML, plus a hand-written Amadeus mapping as Gemma’s worked example. First real backtest on Amadeus-only data looked promising but tiny: Chronos 4.4% vs baseline 10.4% over 18 estimated dates. I flagged it as too small to trust.

Agent

First Gemma pass mapped only 3 of 12. The failures weren’t the model’s fault: (1) Ollama’s default ~4k context silently dropped the start of long prompts, which is where the instructions were, so Gemma answered as if it never saw them; (2) the sample-shrinker cut deep SOAP responses at depth 14; (3) retry feedback only said ‘check the path’. Fixed all three: native Ollama API with a larger context, deeper samples, and feedback that says exactly where a path breaks.

▸
map_supplier (Gemma feedback)
aeroprime attempt 1

aeroprime attempt 1
'fareComponentList' does not exist under …originDestinationOptionList[*]; keys there: ['fareComponentGroupList']. did you mean 'fareComponentGroupList'?
Agent

Gemma was generating at ~1.4 tokens/s. Measured why: Gemma 4’s thinking mode is on by default in Ollama.

▸
Bash
same tiny prompt, think=true vs think=false

$ same tiny prompt, think=true vs think=false
think=True: 58s, 248 tokens, wrong answer | think=False: 5s, 9 tokens, correct answer
Agent

More language gaps surfaced by Gemma’s near-misses, each fixed and tested: {*} for fares keyed by opaque IDs (AIX), [][] lint for lists of lists (BatikAir), [-1]/[last] for the final flight leg, sum: for fares split into charges, and an automatic ‘^’ rewrite when Gemma writes field paths from the document root. Also caught myself: a new ‘base fare vs total’ check flagged AIX, but AIX’s fareAmount was already the per-passenger total; the bigger ‘total’ summed all passengers. Narrowed the check to same-passenger siblings.

Agent

With 7 suppliers mapped the backtest said 308% error on ‘seen’ fares, which should be near-exact. Investigated instead of reporting it: one consolidator lists some dates at ₹100 (seats on request), and a lowest-fare calendar picks that every time.

▸
Bash
share of fares below 30% of route median, by supplier

$ share of fares below 30% of route median, by supplier
kandhari: 528 rows at ₹100 on a route whose median is tens of thousands

Fix: drop fares under ₹500 and per-route outliers outside 0.25x-4x the median, and count them. Seen-fare error back to 1.3%.

You

Rest of the suppliers need to be mapped, except OMS and Aeroprime. Try Gemma again now that RAM is free.

Agent

Gemma mapped Emirates on attempt 6. IndiGo, SpiceJet and an NDC carrier didn’t converge in 11 attempts each, even though the feedback named the exact location, so I hand-wrote those and labelled them hand-written. IndiGo and SpiceJet store discounts as positive amounts, so a plain sum would add them: added item filters and signed sums.

▸
Bash
count Navitaire charge types and discount signs

$ count Navitaire charge types and discount signs
charge types: FarePrice, Tax, Discount | discount amounts: positive 27/27

Mapping: sum:charges[ChargeType!=Discount].Amount – sum:charges[ChargeType=Discount].Amount. Also: connecting itineraries are priced per leg, so only the searched route is kept (11,195 leg rows dropped).

Agent

Honest result with 11 suppliers: coverage 17% -> 23%, but Chronos-Bolt small (20.1%) did NOT beat the weekday baseline (18.4%) on estimated dates. Put that in the README and the post rather than the earlier small-sample number.

You

Can we increase the accuracy since you downgraded to 64 because of less RAM?

Agent

Correction first: 256 -> 64 was Chronos’s batch size, which doesn’t change accuracy, only memory. What can help is model size and ensembling, so I measured five options on the real backtest.

▸
Bash
backtest –models naive,chronos,chronos-base,ensemble,ensemble-base

$ backtest --models naive,chronos,chronos-base,ensemble,ensemble-base
baseline 18.4% | chronos small 20.1% | chronos base 17.6% | small+baseline 17.9% | base+baseline 15.1%

Picking the best of five on one backtest flatters the winner, so I re-checked on different cutoffs: on a 63-date subset it went the other way (20.3% vs 19.0%). Then ran all 14 cutoffs (591 estimated dates): ensemble 16.2% vs baseline 18.7%. Shipped the ensemble and reported both checks.

Agent

Deployment on Render: Chronos runs where the logs live and writes a compact forecasts.json (columnar, 1.85 MB -> 565 KB so it fits a Secret File); the API serves it with no PyTorch. A public synthetic demo plus a private service behind a login, upgraded to Starter + disk with the Render credit. Redesigned the UI: a fare profile strip above the month, the route as the headline, a panel explaining each picked day, keyboard navigation.

You

The private site shows the browser’s sign-in popup, and cancelling it shows raw JSON. The route dropdown opens with giant faint text.

Agent

Replaced Basic-auth popups with a sign-in page and a signed, HttpOnly, SameSite=Lax session cookie; API calls without credentials get 401 JSON without the popup header; pages redirect to /login with same-site-only next targets. Tests cover open redirects, forged cookies and script injection via next. The headline codes stay large while an invisible native select sits on top, so the list opens at normal size in theme colours.

▸
Bash
pytest -q

$ pytest -q
60 passed

My First User

During beta testing, our ops team switched Fare Calendar on for a few of our agents and some of our own staff.

They liked it, and they told our tech team it’s one of the features most in demand from our clients and agents. They’d been asking for something like this.

Prize Categories

  • Best Use of Gemma: Gemma 4, run locally, writes the supplier mappings that the whole pipeline depends on (7 of 11), verified by code against real responses.
  • Best Use of Render: two services from one blueprint. A public demo on the free plan, and a private Starter service with a persistent disk for nightly forecast uploads, behind a sign-in page.

What shaped the approach: “The Fare Observation Schema I’d Build Before a Flight Scraper” by arritola92 is why “seen” and “estimate” are kept apart and nothing is presented as bookable. “Chronos vs Toto: Zero-Shot Forecasting Benchmark Results” by Team Parseable is why I tried a zero-shot forecaster before training anything. “I priced 150 flights across November…” by ishemah matched the weekday effects I built the synthetic demo around.

Built with an AI coding agent (Claude Code) and reviewed by me. The product itself runs on open-weight models: Gemma 4 and Chronos-Bolt.

Leave a Reply