Portfolio
Case study 01 / Causa

Case study 01 · AI & analytics engineering

Causa

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.

Role
#built_from_scratch
Timeline
2026
Core stack
FastAPI · PostgreSQL · HTMX
Status
Deployed on Render
10
Pipeline phases
9
Modular analytics pipelines
99.9%
Less LLM input, 479K → 430 tokens
0
Hard-coded % thresholds

01 · How it works

Upload a file, get a diagnosis

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.

01

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.

02

A z-test, not a threshold

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.

03

Exact decomposition

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.

04

The model never does maths

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.

The full pipeline

Ten phases, one pass. Phases 1 and 2 run at upload, engine.py orchestrates the rest.

Upload dataset
CSV · Excel · all sheets
Automatic column mapping
An LLM maps arbitrary headers to canonical business entities, so Cust_Nm customer name and buyer all land in the same place.
ingest.py, phase 1
Cleaning & KPI discovery
Multi-sheet workbooks are stacked with a queryable origin column, then the available KPI set is derived from the mapped schema.
ingest.py, phase 2
SQL KPI engine, baseline
One pass computes every default KPI for the current and previous period, plus the per-day series the significance test needs.
sql_modules.py, phase 3
Significance test, is the change real?
Welch z-score across daily values. Flagged only when |z| ≥ 1.96 and the move clears a 2% materiality floor.
events.py, phase 4 · decision
Not significant
Recorded as a movement, but never drills down. This is what stops the report chasing noise.
significant only
Decomposition
Exact arithmetic splits the headline change into volume, basket and interaction effects that sum to the total.
decomposition.py, phase 5
Revenue
Trend, period split, contribution.
Customer
Retention split, lost, retained, new.
Product
Concentration, what explains 80%.
Region
Two-level drill into the worst region.

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.

Merge into structured evidence
All findings collapse into one machine-readable evidence object, figures, comparisons and the query behind each one. Not prose.
rca.py · insights.py, phase 6
Root-cause engine & context
Ranks candidate causes, then asks only the internal and external context questions relevant to this primary event, four checkboxes, not forty.
rca.py · context.py, phases 7 to 8
LLM insight generation
Strict-JSON narrative written from the computed evidence only, about 430 input tokens. The model phrases findings; it never calculates them.
llm.py, phase 9
Interactive dashboard
Report, KPI cards, insight panels each carrying their own SQL with run and edit buttons, plus a plain-English chatbox and a read-only SQL playground.
templates/, phase 10
Business recommendations

02 · Challenges

Four things I had to solve twice

Challenge 01

Everyone's columns are named differently

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.

Challenge 02

I could not list every external factor

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.

Challenge 03

The follow-up question the report doesn't answer

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.

Challenge 04

Paying the API to check a CSS change

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

What it still doesn't do

AI-only mapping

No API key means no upload. A deterministic fuzzy-match fallback is the obvious next addition.

PostgreSQL only

No SQLite fallback, the analytics lean on Postgres window functions. Deliberate, but it raises setup cost.

Correlation, not proof

The engine quantifies where a change came from. External causes still come from the user's context answers.

Single dataset scope

One active investigation at a time. Multi-tenant history and run-over-run comparison are on the roadmap.

No seasonality model

Period-over-period with a z-test catches noise, not annual seasonality. Year-on-year baselining is next.

Read-only by design

The playground and chatbox execute SELECT only. A limitation on purpose, a user-facing SQL box shouldn't write.

04 · What I learned

What I took away from building it

Don't make the user do work a model can do

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.

Give the model the narrow job

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.

A fixed list of options is a guess

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.

Build the off switch early

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.

Showing the query is the feature

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.

See it run

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.