Any file, no renaming
An LLM maps whatever the columns are called onto the fields the engine needs. buyer_id, c_id, Cust ID all land on the same place. The user changes nothing.
Case study 01 · AI & analytics engineering
A diagnostic engine that turns a raw business file into KPIs, statistically validated change detection, quantified root causes, and an AI-written executive summary, where every number is backed by a SQL query you can open and re-run.
01 · How it works
Revenue drops and the same four questions come up every time. Is the drop real or is it noise? Fewer orders or smaller orders? Did everyone spend less, or did specific customers leave? Is it broad, or is one giant transaction dragging the total? Those four repeat, which is what made the reasoning worth automating rather than the charting.
An LLM maps whatever the columns are called onto the fields the engine needs. buyer_id, c_id, Cust ID all land on the same place. The user changes nothing.
Daily values for both periods go through a Welch z-test. A change has to be statistically real and materially large before anything chases it. No hard-coded 10% rule.
Revenue is split as Orders × AOV using an identity, so the parts sum to the whole. That is how it can say orders explain 60% of the drop instead of comparing two percentages.
Every figure is computed in SQL first and handed to the LLM as finished sentences. It writes the summary, it does not calculate. That is also what cut the input from 479K tokens to 430.
Ten phases, one pass. Phases 1 and 2 run at upload, engine.py orchestrates the rest.
Cust_Nm customer name and buyer all land in the same place.|z| ≥ 1.96 and the move clears a 2% materiality floor.9 modular pipelines in total, revenue, profit, margin, customer, product, region, time, order/AOV, discount & returns. Each runs only when a KPI it depends on was flagged.
02 · Challenges
The whole product depends on someone uploading their own file, and no two companies name columns the same way. My first answer was to tell users to rename their columns to match my schema. I dropped that quickly, it puts a wall in front of the product and nobody is going to edit a CSV before trying a tool.
So I tried keyword matching instead. buyer id becomes customer_id, and
that works until it doesn't. I could not promise the mapping was right every time, and I had no
answer for c.id or c_id or the hundred other things a column can be
called. Hand-coding that list is not a solvable problem.
What I actually needed was something that could read a column name and work out what it meant, which is exactly what a language model does. So the mapping runs through an LLM at upload, and the user changes nothing.
External context matters, so I put checkboxes for the factors I could think of. Partway through I realised the obvious thing: the factors I had not thought of are the ones that matter most, and a fixed list can never be complete. I added a free-text field so the user can name anything I missed, and the report takes it into account.
Reading an insight, a business analyst immediately thinks "but could it be this instead?" The report cannot anticipate that, so I added a chatbox. The obvious build is to hand the model the data and let it answer, which is the one thing I did not want, because then it is doing arithmetic and it will get it wrong or invent a number.
So the model only writes SQL. The query runs against the Neon database and the database returns the answer. The maths happens where maths belongs, the numbers are correct by construction, and because only a query goes in and out the token cost stays small. The query is shown as well, so if you can read SQL and think it's wrong, you can fix it and re-run it yourself.
Every UI tweak meant reloading the app, and every reload fired a real LLM call. Two problems. The build was costing me money to look at, and each run took long enough that a small styling change became a slow loop.
I put a bypass in the UI. Toggle it and the app skips the API call, serving a canned response instead, so the interface can be worked on without touching the model at all. It's a mock behind a flag, and the honest next step is caching keyed on a hash of the prompt, which gives you the real output while still only paying for it once.
03 · Limitations
No API key means no upload. A deterministic fuzzy-match fallback is the obvious next addition.
No SQLite fallback, the analytics lean on Postgres window functions. Deliberate, but it raises setup cost.
The engine quantifies where a change came from. External causes still come from the user's context answers.
One active investigation at a time. Multi-tenant history and run-over-run comparison are on the roadmap.
Period-over-period with a z-test catches noise, not annual seasonality. Year-on-year baselining is next.
The playground and chatbox execute SELECT only. A limitation on purpose, a user-facing SQL box shouldn't write.
04 · What I learned
Asking people to rename their columns would have been easier to build and would have killed the product. The friction you add at the start is the friction that decides whether anyone gets to the useful part.
An LLM writing SQL is reliable. An LLM doing arithmetic is not. Once I drew that line the answers became correct by construction, and the cost dropped as a side effect rather than as a goal.
Any dropdown I write is limited to what I thought of on the day. Leaving an escape hatch for the things I missed turned out to matter more than the options I chose.
I spent real money reloading a page to check spacing before it occurred to me that the model didn't need to run at all. Anything you call on every page load needs a way to not call it.
Attaching the SQL to every number started as a way to prove the tool wasn't making things up. It ended up being the thing that makes it usable, because a user who disagrees can edit the query instead of abandoning the answer.
Upload a sales CSV or use the sample dataset, you get a full investigation, insight panels with their SQL attached, and a chatbox that turns questions into queries.