A natural-language analytics layer over a SQL warehouse. An LLM planner writes the query, a sqlglot AST guard proves it is a single read-only statement before anything executes, a read-only connection with a timeout runs it, and a shape heuristic picks the chart. The SQL comes back with the answer, every time.
This is a public reference implementation. The production version at the Agulhas Code client (under NDA) runs against their multi-table Postgres warehouse with tenant isolation and their own schema documentation. The reference implementation here reproduces the same architecture — the same planner contract, the same AST guard, the same execution and charting path — against a seeded SQLite demo warehouse anyone can re-run. Accuracy and adoption figures from that engagement are not published.
Why this exists. Most SMEs sit on a warehouse only the engineer who built it can query. Stakeholders ask "what's our churn by tier last quarter?" and either wait days for a dashboard or accept a half-answer from a stale report. The hard part is not generating SQL — models are good at that. The hard part is being able to run generated SQL against production without lying awake.
make install
make seed # build the demo SQLite warehouse (5 tables, ~670 rows, seed 42)
make eval # 15 golden questions, offline, no API key
make run # uvicorn on :8000curl -s localhost:8000/ask -H 'content-type: application/json' \
-d '{"question": "Show monthly revenue for the past year"}'The guard works standalone, which is the piece most people want:
from api.guard.sql_guard import guard_sql, GuardError
print(guard_sql("SELECT country, COUNT(*) FROM customers GROUP BY country"))
# → 'SELECT ... LIMIT 1000' (LIMIT injected)
guard_sql("WITH x AS (DELETE FROM users RETURNING id) SELECT * FROM x")
# → GuardError: forbidden statement type inside CTEEnd to end:
from pathlib import Path
from api.planner.llm_planner import LLMPlanner
from api.planner.schema_retriever import SchemaRetriever
from api.guard.sql_guard import guard_sql
from api.exec.runner import QueryRunner
from api.charts.picker import pick_chart
db = Path("demo/demo.db")
plan = LLMPlanner(retriever=SchemaRetriever(db)).plan("Show monthly revenue for the past year")
result = QueryRunner(db, timeout_seconds=8.0).run(guard_sql(plan.sql, dialect="sqlite"))
print(pick_chart(result.columns, result.rows).type, result.row_count, result.elapsed_ms)flowchart LR
Q[question] --> S[schema blocks]
S --> P[LLM planner]
P --> G{AST guard}
G -->|reject| E[GuardError + reason]
G -->|accept| L[LIMIT injected]
L --> X["read-only execute, 8s cap"]
X --> C[chart shape heuristic]
C --> R["SQL + rows + chart spec"]
POST /askvalidates the question length.SchemaRetrieverreadssqlite_masterandPRAGMA table_info, annotates columns with descriptions, and rendersCREATE TABLEblocks for the prompt.LLMPlannersends a system prompt, two few-shot examples, the schema, and the question to Claude or GPT, then strips fences from the reply.guard_sqlparses with sqlglot and enforces: one statement, a read-only root, no forbidden node type anywhere in the tree, and a LIMIT.QueryRunneropens the database read-only, installs a timeout, executes, and fetches.pick_chartselects a shape from the first row's column types.- The response carries the executed SQL alongside the rows, so any answer can be audited or pasted into a client.
| Rule | Blocks |
|---|---|
| Must parse | Anything sqlglot can't read — a parse failure is a rejection, not a pass |
| Exactly one statement | Stacked queries (SELECT 1; DROP TABLE users) |
| Read-only root node | Root must be Select, Union, or a With wrapping either |
| Forbidden node walk | Delete, Update, Insert, Drop, TruncateTable, Alter, Create, Merge, Grant, Revoke, Command — anywhere in the tree, including inside CTEs and subqueries |
| LIMIT injection | Missing LIMIT gets the default (1000); an oversized one is capped |
Chart shapes: kpi for a single cell, line for a date-ish first column, bar for a categorical first column, scatter for two numerics, table as the fallback.
| Decision | Why |
|---|---|
| AST walk, not regex | Catches creative phrasings — WITH x AS (DELETE …) inside a CTE — that string filters miss. A regex for "DROP" is security theatre. |
| A parse failure is a rejection | If the guard cannot understand the query, it has no basis for saying it is safe. Fail closed. |
| The executed SQL is part of the response | An analytics answer nobody can check is a rumour with a chart attached. |
| Result-shape heuristic for charts, not a second LLM call | Deterministic, free, instant, and correct for the five shapes that cover almost all business questions. |
| Read-only connection and a guard | Defence in depth: the guard is the control, the read-only handle is the thing that saves you when the guard has a bug. |
- There is no schema retrieval.
get_relevant_tables()discards the question — the parameter is explicitly deleted as unused — and returns every table. There is no embedding code and no vector store in this repo. The "retrieved schema slice for a 200-table warehouse" this README used to claim is not implemented; on the five-table demo it doesn't matter, and on a real warehouse it is the first thing you would have to build. - SQLite, not PostgreSQL. The read-only role and
statement_timeoutin the old architecture diagram are actually amode=roURI flag plus a Python progress handler polled every 1000 VM instructions — which cannot interrupt blocking I/O or a single long-running operation. - No authentication or authorization on
/ask. Anyone who can reach the port can run arbitrary SELECT over every table, andGET /schemadumps the full schema unauthenticated. There is no row-level, column-level, or tenant filtering. - The guard blocks writes but not cost. LIMIT is enforced only on the outermost SELECT; inner subquery limits are untouched, and a
Unionroot always gets the default limit while discarding a user LIMIT. Cross joins, correlated subqueries and full scans are all permitted. The cost estimator that would fix this lives in the sibling sql-guardrails. - The default eval does not test the LLM. Without
--onlineit uses each question's committedreference_sql, somake evalmeasures the guard and executor only. Themust_containgrading is also skipped offline, and is a case-insensitive substring check rather than semantic correctness. - Per-request object construction, no pooling. A fresh retriever, runner and planner are built on every call — a new SQLite connection per request and a full schema re-read per question.
PRAGMA table_info({table})is f-string interpolated. The name is sourced fromsqlite_masterso the risk is low, but it is unparameterised SQL construction inside the security-conscious path.- The published accuracy figures have been removed. The previous version cited ~94% accuracy on 60 questions, 3–8s median latency, zero destructive incidents, and a 70% analyst-ticket reduction. The eval set contains 15 questions, no results artifact is committed, and none of the operational figures are traceable to anything in this repo.
insightengine/
├── api/
│ ├── main.py # FastAPI: /health, /schema, /ask
│ ├── planner/ # schema_retriever · llm_planner (Anthropic / OpenAI)
│ ├── guard/ # sqlglot AST allowlist + LIMIT injection
│ ├── exec/ # read-only runner with a statement timeout
│ └── charts/ # result-shape → chart spec
├── demo/ # seed.py (Faker, seed 42) + committed demo.db
├── eval/ # golden_questions.yml (15) + run_eval.py
├── tests/ # 30 tests
└── Makefile # install · seed · run · eval · eval-online · test
make test # 30 tests
make eval # 15 golden questions, offlineThe guard suite is the substantial part: parametrised coverage of top-level write statements, writes hidden in CTEs and subqueries, multi-statement injection, and CALL/EXEC. CI runs it on every push.
Darrshan Govender · Agulhas Code · Durban, South Africa