Projects › Ops Analytics

Watch the demo (AI-narrated, 1:28)

Narrated with a generated voice; captions included.

Recorded on the live app with real answers: a revenue question with its steps and the SQL guard's verdict, the sources and SQL behind the answer, a follow-up for the other property, guest phone numbers refused, and the daily check catching a mispriced suite.

Read the narration

A revenue manager told me a custom question costs her half a day of pivot tables.

So I built this: she just asks.

It's a simulated hotel group, and all the data's made up.

Which room type at Mountain Lodge earned the most this year?

It writes one SQL query,

and before anything runs, a guard checks it.

Here's the guard's verdict: read-only, and only these tables.

And here's where the answer came from.

The tables it read, and the revenue definition the hotel agreed on.

It didn't guess what revenue means.

It looked it up.

And the SQL's right there to check.

Now a follow-up.

And at Beach Resort?

It works out I mean the same question for the other property,

shows me how it read it, and answers.

If I ask for guest phone numbers as an analyst, it says no,

and even if the model slipped, the guard would stop the query.

Every morning a check runs five rules with no model at all.

Here it caught a suite priced at fifty dollars instead of about five hundred.

On my test questions it gets ninety-nine percent right,

and every held-out one,

for about twenty-eight cents per thousand questions.

It's live, with no sign-in.

The link's below, and so is the code.

Try the live app My own-voice recording will replace the generated narration.

Free hosting: if the app is asleep, click ‘wake up’ and give it about 30 seconds.

Case study · stage 8

Hotel Ops Analytics Agent

Managers ask the bookings database questions in plain English and get a table, a chart and a short answer, always with the SQL that produced it. A daily check finds anomalies and posts them to Slack.

Independent project: a simulated client engagement for the same fictional hotel group as the other stages, with synthetic data. Every number below comes from a saved report or test run in the repository. The database has 12 months of synthetic bookings with five anomalies planted on purpose.

01 · Problem

Problem

The revenue manager said a custom question means “half a day” of pivot tables, and that a number without its query won't go in a board pack. A rate loaded wrong for a week went unnoticed until the month-end report.

02 · Approach

Approach

  • Plan: Claude Haiku 5.5 returns structured JSON: a clarifying question (only for “our hotel” without a name, or occupancy without a date), a refusal, or one SELECT and a chart.
  • Guard: every query is parsed before it runs: one SELECT, allowlisted tables, a LIMIT of at most 500, and guest names, emails and phones only for the manager role. The connection is read-only underneath, with a 3-second limit.
  • Answer: a second call writes 1–3 sentences from the rows; numbers that aren't in the result are flagged. The SQL is always on screen.
  • Daily check: five SQL rules (occupancy drop, cancellation spike, refund spike, activity no-shows, rate error) against the previous 28 days, no model, posted to a Slack incoming webhook by a scheduled GitHub Action.
03 · Architecture

Architecture

  1. A manager asks in the Streamlit appChat, history, a share link and the steps shown.
  2. limits.pyPer visit, per minute, daily questions and cost.
  3. rewrite.py (Claude Haiku 5.5)A follow-up becomes a standalone question.
  4. retrieval.py: BM25 over 19 documentsSchema notes and definitions the planner needs.
  5. agent.plan_and_run (Claude Haiku 5.5, JSON)Plans the query.
  6. sqlguard.pyParser-based: reads only, no personal-data columns unless the role is manager, a row limit.
  7. SQLite, read-onlyThe query runs on a read-only connection.
  8. agent.stream_answer (Claude Haiku 5.5, streamed)The answer is written from the rows, with the SQL shown.
  9. A redacted request log; a daily GitHub Action runs anomaly.py and posts to Slack.
From docs/ARCHITECTURE.md in the repository.
04 · A real output

A real output

“What was total revenue at Beach Resort in October 2026?” (dev case q04)

SELECT SUM(b.total_usd) AS total_revenue_usd FROM bookings AS b JOIN properties AS p ON b.property_id = p.id
WHERE p.name = 'Beach Resort' AND b.status <> 'cancelled' AND STRFTIME('%Y-%m', b.check_in) = '2026-10' LIMIT 500

Total revenue at Beach Resort in October 2026 was $334,216.

The agent's SQL and answer in the version 1.3.0 run (eval/reports/sql-2026-10-26-dev-protocols.jsonl, run 1); the result matches the reference query's.

05 · Results

Results

Text-to-SQL eval, Claude Haiku 5.5, version 1.3.0 (with the hotel-policy step, 2026-10-26), 3 runs each
MetricDevHeld-out
Execution accuracy (the query's result matches the reference query's)99% (167/168)100% (27/27)
Unsafe requests refused (writes, schema changes, personal data, injection)100% (27/27)100% (9/9)
Clarifying question asked when needed100% (9/9)100% (3/3)
Follow-up questions (multi-turn)100% (24/24)100% (9/9)
Plan + query + answer time per question, p954.31 s4.53 s
Cost per 1,000 questions$0.28$0.29
Hotel-policy questions (new, 5 × 3)15/15 (11/15 before a grader fix)

In the browser on the live app (2026-10-28, 20 dev questions in one visit, Enter pressed → answer finished rendering): p50 4.28 s, p95 5.47 s. The browser adds streaming and rendering to the eval's plan + query + answer time.

The first published run (version 1.0, 2026-10-24) was 98% dev (144/147) and 95% held-out (20/21, run once) at $0.30 per 1,000 questions. Reports: eval/reports/sql-2026-10-26-dev-protocols.md, sql-2026-10-26-heldout-protocols.md and sql-2026-10-26-policy-rescored.md in the repository.

Anomaly check, every day of two synthetic years
YearPlanted anomalies caughtFalse alarms
Main (thresholds set here)5/51 in 11 months
Held-out (other dates)5/50
06 · What the eval taught me

What the eval taught me

The first recorded run scored 84%. Half the failures were the grader: the model answered “Beach Resort” where the reference said beach-resort, returned a full ranking with the right answer first, and gave a share as 34.21% instead of 0.342. I fixed the grader and re-scored the saved queries without calling the model: 84% → 92% on the same answers. The rest were real, and some were mine: my prompt told the model to ask for a time frame and used “cancellations” as the example, so it asked instead of answering.

Before any model run, a free check that every reference query passes the guard found a guard bug: COUNT(*) looked like SELECT * over the guests table.

07 · Trade-offs

Trade-offs

Trade-offs
ChoiceWhyWhat it costs
A parser-based guard plus a read-only connectionThe prompt is a request; the guard is a controlAn unusual valid query can be rejected (one retry)
Two model callsThe answer is written from the rows, not guessed$0.28 per 1,000 questions, p95 4.3 s plan + query + answer
Deterministic anomaly rulesEvery alert can be explained line by lineA new kind of anomaly needs a new rule
SQLite shipped with the appDeploys free with nothing to set upPostgres with a read-only role is the scale path
08 · Engineering

Engineering

  • Tests: 49, 85% coverage: the guard, the read-only connection and time limit, the agent with the model replaced, each planted anomaly, the Slack job without network, and the app via Streamlit AppTest.
  • CI gates without an API key: every reference query passes the guard, the detector catches 5/5 on both years with at most one false alarm a month, and the saved dev run re-scores to the same verdicts.
  • API spend: about $0.15 for every eval run.
09 · Business case (projection)

Business case (projection)

A projection, not a result: a custom question in seconds instead of half a day, and a mispriced room caught in a day instead of at month-end. In this data the Suite was loaded at $50 instead of $515 for five nights, about $2,300 of lost revenue per room sold. A pilot would measure report hours per week and days from an anomaly starting to someone acting on it.

10 · Limitations

Limitations

  • SQLite only. Postgres via DATABASE_URL is designed (sqlglot can transpile the guarded SQL), not built.
  • Demo roles without sign-in. A real deployment would use the stage 7 tool server's roles or SSO.
  • I wrote the eval questions with the schema in front of me. A client's real questions will be messier.
  • Conversation history lives in the browser tab (session state), not a database. Stage 6 stores it in Postgres.
  • On Streamlit Cloud the request log and daily counters reset when the app restarts.
11 · What's next

What's next

  • Deploy on Streamlit Community Cloud and connect a Slack channel
  • Postgres with a read-only role per hotel group
  • Questions written by a real revenue manager, not by me

See every stage →