Skip to content

Latest commit

 

History

88 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

💬 Ask Your Data

This repo started as a Raspberry Pi voice assistant, became an analytics capstone, and now speaks again — with governed SQL between the question and the answer.

CI Python Tests LLM Keyless Voice License: MIT

▶ Live demo: ask-your-data-kp.streamlit.app — type your own question. No API key required, and none is configured on the deployment: without one, the SQL is written by a deterministic compiler (engine/planner.py) that binds your words to the warehouse's own columns, values and join graph. Bring your own key in the sidebar to let a language model write SQL. Voice needs no key either — faster-whisper and Piper run inside the same process, so you can ask out loud and hear the answer back on the deployed app with nothing configured. Same guard, same verifier, same executor whether the question was typed or spoken.

The interface is organized around four jobs: Ask keeps the governed answer flow focused, Data catalog searches authorized tables/columns/values, and Trust center collects identity scope, runtime controls, session telemetry, and a versioned release-assurance pack with benchmark, red-team, role-policy, and cost-envelope evidence. Project brief provides a two-minute recruiter walkthrough with inspectable engineering evidence and explicit limits. Model and voice settings stay collapsed until they are needed.

(First load builds the schema index, which downloads a 79 MB embedding model once per container, and profiles the warehouse for the compiler — the spinners say so. The header carries a build hash of the running source, so you can tell a fresh deploy from a warm container still serving the old one.)

Say or type a plain-English question about any of my portfolio datasets and get a real answer — with the SQL that produced it shown right next to the number.

In one paragraph. Ask 71 tables across 11 business domains a question in plain English, by voice or by typing, and get an answer with the query beside it. Nothing here answers from memory: every number on screen was returned by a SELECT you can read. Two engines write that SQL — a deterministic compiler that needs no model at all, and, if you bring a key, a language model — and both pass through one read-only guard, one structural verifier and one default-deny access policy. The model may select result-row indices, but cannot supply factual answer prose: a deterministic renderer binds every displayed value to its row and column. This prevents invented summaries, not incorrectly interpreted questions or incorrect SQL. Six certified metrics carry an owner, a committed definition and a value CI re-checks. Speech runs inside the process on open models, so the public demo listens and answers aloud with no account and no second service. See the September release verification for current test evidence and the remaining production integration requirements. The RAG review separately measures retrieval on 39 reference questions and 22 development paraphrases, with CI recall gates. The release assurance and business case ties the requirements, stakeholders, options, UAT, rollout, evidence and remaining production decisions to one machine-readable release pack.

The rule No number without a query — and it is enforced, not just prompted
Keyless engine 58-question contract: 46 right, 0 wrong, 12 refused. Refusing is the feature
Retrieval Hybrid RRF, 100% table recall on 2,253 schema tokens against 12,741 for the full catalogue
Governance Read-only guard · structural verifier · OIDC principal + column masking · optional JSONL audit sink
Release assurance 6 governed suites · 15 red-team cases across 9 threat families · 5 enforced runtime budgets
Voice faster-whisper + Piper, in-process, checksum-pinned to immutable revisions

A keyless turn end to end: the question, the pipeline strip with PLAN lit, the retrieved tables, the answer, the compiler's binding trace, the read-only guard, the verifier, the SQL, and the physical plan

A question nobody pre-registered, answered with no API key and no network call. PLAN is lit rather than GENERATE, because no model wrote this. The trace under the number names the table, the metric and the filter, and shows which words in the question paid for each one — then the same read-only guard and the same structural verifier the model's SQL goes through.


Where this repo came from

Years ago I built IVA — a little Python voice assistant that ran on a Raspberry Pi. You said "Hello Eva", it woke up, told you the weather, played a song, made a joke. I was proud of it. It was also, let's be honest, a hobby project. The current voice path keeps the useful interaction and drops the old illusion: speech is transcribed, shown for confirmation, and only then enters the same inspectable data pipeline as a typed question.

Then I spent a career break building an analytics portfolio with one non-negotiable rule: nothing ships unless a test proves it. Hospital revenue cycle, workforce attrition, GL reconciliation, cold-chain supply chain, wholesale cross-sell, retail chain P&L, marketing attribution, clinical data management, transaction monitoring, a legacy-to-Fabric migration, and the dbt project that models the warehouse itself — every dashboard number reproducible from the command line, every claim locked in CI. This assistant reads the datasets from eleven of those repos.

When I looked back at IVA sitting next to them, I had two options: delete it, or rebuild it into something that belonged. I rebuilt it. The voice-assistant code is gone, but I kept the git history on purpose — scroll back far enough and you'll find the wake-word notebook. Portfolios that pretend their author sprang fully formed are lying. This one shows the pivot.

The question every dashboard can't answer

Each of those projects ends in a dashboard, and every dashboard answers the questions somebody anticipated. Denial rate by payer? Page one. AR aging? Page three. But the question an executive actually asks on a Tuesday afternoon is the one nobody anticipated:

"Which payer type collects the least of what it bills?"

The modern answer is "ask an LLM." The modern problem is that a chatbot answering from its own head is worse than no answer — it will give you a confident, plausible, wrong number, and you'll put it in a board deck.

So this project is built on a single rule.

The rule: no number without a query

The language model never answers from memory. Its only job is to write SQL. The SQL runs against a real warehouse. The number comes from the database. The SQL is shown next to the answer so anyone can audit it. And if the question can't be answered from the loaded tables, the assistant says so instead of inventing something.

Ask it the question above and the answer — locked by this repo's test suite, not just typed into a README — is Self-Pay, collecting about 19 cents of every allowed dollar, with the GROUP BY right there to check:

> which payer type collects the least of what it bills?

Self-Pay has the lowest net collection rate — about 19% of the allowed amount,
far below every insured payer type.

  SQL:
    SELECT payer_type, SUM(paid_amount) / NULLIF(SUM(allowed_amount), 0) AS ncr
    FROM healthcare_fact_claims c
    JOIN healthcare_dim_payer p ON c.payer_id = p.payer_id
    WHERE status = 'Paid' GROUP BY 1 ORDER BY ncr LIMIT 1

It reads from 71 tables across 11 business domains, vendored (synthetic data only) from the eleven repos above — so one interface can answer questions about hospital claims, flight-risk employees, GL exceptions, order fill rates, wholesale customers, and migration verdicts.

The demo that wasn't real, and what I did about it

For a while this repo had two modes and only one of them was real.

With an API key, the model wrote SQL for whatever you typed. Without one — which is every visitor to the public deployment, because putting my key on a public URL is an unmetered spend surface — the app disabled its own chat box and offered a dropdown of 39 pre-registered questions whose SQL was committed in evals/golden_questions.yaml.

That fallback was honest. Every answer was labelled reference SQL, not written by the model. It was still the wrong product, and the reason is the sentence this README opens with: the interesting question is the one nobody anticipated, and the deployed app could not answer one. I had built a thing that accused dashboards of only answering pre-planned questions, and then shipped a demo that only answered pre-planned questions.

So I wrote a second engine. engine/planner.py compiles the question itself. No model, no key, no network call, nothing to pay for — and it answers questions nobody registered in advance.

How you compile a question without a model

You need to know things about the warehouse that a schema dump does not tell you. engine/semantics.py works them out by probing DuckDB, because a hand-written mapping file is exactly the kind of claim this repo refuses to make without a test:

Inferred From what This warehouse
Column roles — key, measure, dimension, date, flag type, name, cardinality 285 measures, 197 dimensions, 157 keys, 46 dates, 23 flags
Grain — the narrowest unique key uniqueness probes 51 of 71 tables
Join edges — shared column, unique on one side column overlap + uniqueness 56 edges, none crossing a domain
Value lexicon — the words a question can name SELECT DISTINCT on every low-cardinality dimension 797 phrases

That last row is the one that makes it work. Nobody wrote down that Denied is a status — the database knows, so "how many denied claims" becomes WHERE status = 'Denied' without a synonym file. The join graph is scoped inside a domain on purpose: engine/verify.py already rules a cross-domain join an error, so a planner able to build one would be planning a query the verifier exists to block.

On top of that sits a small grammar — aggregation verbs, group markers, ordering, limits, comparisons — and a binder that scores every candidate plan by how much of your question it can account for. Below a floor, it refuses and tells you which words it could not place.

The number that matters is the one that isn't there

The planner is graded on two contracts, and the gap between them is the finding:

questions match differs refused SQL errors
evals/planner_questions.yaml — ordinary ad-hoc questions 58 46 0 12 0
evals/golden_questions.yaml — written to need a model 39 5 1 33 0

differs is the column that matters. A refusal costs you an answer; a disagreement costs you a wrong answer, which is the entire thing this project was built to argue against.

The eval was too easy, and I only found out by attacking it

That table said 23 of 25, nothing wrong for about an hour, and it was worthless, because I had written both the binder and the questions. So I stopped adding features and wrote 28 questions I had not designed against — different phrasings, entity counts, date filters, comparisons, open-ended nonsense.

Eight of the sixteen it answered were wrong. A 50% error rate on unseen wording, under a contract reporting 100%. Not crashes — plausible numbers:

Question It said Truth Why
How many denied claims for Medicare? 0 152 Medicare exists in two tables; it joined the one with no denied claims in it
How many stores do we have? 170 620 counted store-months in a fact table
How many distinct customers? 4 120 counted a campaign metric that happened to be unique
Bottom 3 departments by average salary supplier categories HR departments the measure matched the axis word, not the measure word
break down transactions by channel summed a rolling-window feature row counts "transactions" → txn_count_7d via the synonym map
what's the median salary? 940,000 unanswerable summed a column named market_median
list the departments 341 unanswerable summed departments_supplied
How many claims submitted in 2024? crash 0 emitted service_date = 2024 against a DATE

Every one traced to a rule that was too generous, and each is now a named regression test and a case in the contract. git log has the fixes one root cause at a time. That is the honest version of this section: the first number was real and the eval behind it was not, and the only reason I know is that I went looking.

Then I went looking again, at shapes instead of wordings

The second sweep varied the phrasing. A third varied the shape of the question — negation, follow-ups, ratios, asking for two things at once — and found four more, one of them the worst defect this compiler has had:

Question It said Truth
how many claims are not denied? 876 11,124
how many employees are not active? 1,483 417

The grammar had no notion of negation, so it bound status = 'Denied' and returned the exact complement of the question — in a number that looks entirely reasonable, which is what makes it the worst failure available here. Negation now inverts exactly one unambiguous filter and refuses anything it cannot scope, because guessing which filter a "not" attaches to is how you answer the opposite of what was asked.

The other three were quieter and the same shape of mistake — answering a question adjacent to the one asked:

  • "and by region?" was answered from marketing_dim_user, a table nobody had mentioned, because region matched there. This compiler is stateless; a follow-up has no previous turn to attach to, and now says so.
  • "the ratio of paid amount to allowed amount" became "what share of rows have status = 'Paid'" and answered 81.2% — a real number about a different question. ratio was being read as a share word.
  • "total revenue and total margin by department" answered with revenue alone and said nothing about margin. A partial answer presented as a whole one is the quiet version of being wrong.

The contract is 58 questions now, twelve of which must be refused.

The confidence gate has its own version of that story. The first working build scored 8 right and 26 wrong on the golden set, because the gate blended coverage with a structure bonus — and a blend lets a plan buy its way past the floor with shape: bind something as a measure, something as a dimension, and two thirds of the question could go unexplained. Gating on coverage alone took it to zero. Reproducible with python scripts/run_planner_eval.py --sweep:

  gate   match   differs   refused
  0.40      56        16        25
  0.50      56        15        26
  0.60      53         6        38
  0.70      51         1        45     <- shipped
  0.80      50         1        46

Loosening to 0.40 buys five more right answers and fifteen additional wrong answers. Tightening to 0.80 costs a correct answer and still cannot remove the one governed-definition disagreement described below; confidence cannot infer a business rule the schema does not contain.

The one disagreement, which I kept

At the shipped gate exactly one question disagrees, and it is the most interesting thing the compiler does. Asked "what is the overall claim denial rate?", it writes:

SELECT ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'Denied')
             / NULLIF(COUNT(*), 0), 1)
FROM healthcare_fact_claims          -- 7.3%

The house definition — the one in evals/golden_questions.yaml, and the one on the front of this README — divides by adjudicated claims, excluding the ones still pending, and gets 8.2%. The compiler's arithmetic is not wrong. Its definition is, and no amount of schema introspection can discover a convention that lives in a policy document rather than in a column.

That is the honest answer to "do you even need an LLM for this?". For a question whose measure and dimension are named in words the schema uses, no — a compiler is faster, free, and cannot hallucinate. For a question that carries a business definition the warehouse has never been told, yes. The boundary between those two is not a matter of opinion here; it is 97 questions and a table.

How it works

flowchart LR
    MIC[Microphone] --> STT[faster-whisper local<br/>or optional cloud STT]
    STT --> RV[Review transcript]
    RV --> Q[Question in<br/>plain English]
    Q --> M{Exact certified<br/>metric phrase?}
    M -->|yes| CM[Committed definition<br/>metrics.yaml]
    M -->|no| SR[Schema retrieval<br/>exact cosine + MiniLM]
    C[(Schema corpus<br/>71 documented tables)] --> SR
    SR -->|"top 10 tables"| A[Claude<br/>with a key]
    SR -->|"top 10 tables"| P[Compiler<br/>without one]
    L[(Semantic layer<br/>probed from DuckDB)] --> P
    CM --> V
    A -->|"writes SQL"| V{Structural<br/>verifier}
    P -->|"compiles SQL"| V
    A -->|"out of scope"| R[Refuses honestly]
    P -->|"cannot bind it"| R
    V -->|"passes"| G{Read-only<br/>SQL guard}
    V -->|"blocked"| R
    G -->|"SELECT only"| W[(DuckDB warehouse<br/>vendored synthetic data)]
    G -->|"blocked"| X[Rejected]
    W -->|"error goes back for a retry"| A
    W --> S[Answer in plain English<br/>with the SQL and the rows]
    S -->|Listen on demand| TTS[Kokoro local<br/>or optional cloud TTS]
Loading
  1. Warehouse — every vendored CSV loads into an in-memory DuckDB, named <domain>_<table> so the several dim_customer / fact_orders tables from different domains never collide.
  2. Schema retrieval — a read-only exact cosine index embeds one document per table using local, keyless MiniLM ONNX embeddings, then passes only the ten best matches—with business descriptions and real column types—downstream. At 71 records an exact matrix multiply is deterministic and removes the security and operational surface of a vector database. A follow-up always retains tables named in prior-turn SQL, even when the new wording is vague. The same ranking serves both engines: it is what the model is shown, and what the compiler is allowed to plan against.
  3. Question → SQL, one of three ways. An exact, unqualified governed metric phrase uses a policy-owned definition from metrics.yaml, whose expected value CI re-runs. With a key, Claude returns a single SELECT (or a refusal) as a structured tool call, and prior turns replay as context so follow-ups like "and by region?" just work. Without one, engine/planner.py compiles the question against the semantic layer and refuses if too much of it cannot be bound. Everything after this step is identical for all three.
  4. Guard → execute — the SQL is validated read-only and runs on an isolated cursor, capped at a sane row count.
  5. Self-correct if needed — a failed query's real database error goes back to the model for a corrected attempt. At most twice. Then an honest failure.
  6. Answer — actual result rows become a readable summary and selected highlights. In model mode, only validated row indices are accepted; factual prose is rendered from typed values with their original row/column context.
  7. Voice, around the boundary — a completed microphone recording is transcribed, then shown in an editable confirmation field. The free local default path uses in-process faster-whisper for English STT and Piper's male Joe voice for TTS. Self-hosted Speaches and OpenAI are optional alternatives. Automatic speech is off by default to keep first answers fast; enable it in the voice panel or use Listen for an individual answer. The player remains available. The UI explains where audio/text goes and labels generated speech.

The model is untrusted input

That arrow into the SQL guard is the security posture of the whole project: whatever the model writes is treated the way you'd treat user input on a web form. Before anything executes, the statement must be a single SELECT (or WITH), with every mutation verb — INSERT, UPDATE, DELETE, DROP, ATTACH, COPY, PRAGMA, and friends — rejected. Comments, quoted literals, and DuckDB's dollar-quoted strings are stripped before keyword scanning, so WHERE note = 'please DROP TABLE claims' passes and SELECT $$harmless$$; DROP TABLE t does not.

Ask it to "delete all denied claims" and two independent layers have opinions: the model is instructed to refuse (this is a read-only interface), and even if it didn't, the guard blocks the statement before the database ever sees it. The test suite proves the second layer with a row count taken before and after a scripted malicious query: 12,000 claims in, 12,000 claims out.

How do you test an app with an LLM in the middle?

You split it. Everything deterministic is proven in CI without an API key; the model's behavior is graded separately. This is the part of the repo I'd defend in an interview:

  • The guard has an exhaustive suite — every mutation verb rejected, real analytical SQL (CTEs, aggregates, keywords inside string literals) allowed.

  • The golden questions are the accuracy contract: 39 natural-language questions, each with reference SQL and its expected answer (denial rate 8.2%, 1,483 active employees, fill rate 98.8%, top customer Canyon Charcuterie 064...). CI runs every reference query on every push, so the data and the SQL can never silently drift apart.

  • Schema retrieval is measured, not decorative. Ground-truth tables are parsed from each golden question's reference SQL — the tables a question needs are the tables its correct answer selects from, so the labels are derived rather than authored. Hybrid retrieval reaches full recall at k=10 on a schema block 82% smaller than the catalogue; the numbers and the reason both retrievers are kept are further down.

  • The harness suite is my favorite trick: a scripted fake client stands in for Claude, which lets CI prove the control flow no matter what a model might return. The fake "model" writes a bad column → the loop feeds the real error back and succeeds on retry. It writes DROP TABLE → blocked, never executed. It refuses → no retries burned. It exceeds the retry budget → a bounded, honest failure, never an infinite loop.

  • A 15-case adversarial set spans nine threat families: destructive SQL, exfiltration, direct and indirect injection, authorization bypass, unrelated cross-domain joins, denial of service, system-catalog discovery, and secret disclosure. Each prompt names the control and expected behavior. The live-model gate remains RUN REQUIRED after any provider or prompt change instead of borrowing confidence from the offline suite.

  • The compiler has its own contract, and it is the one place a portfolio project is most tempted to cheat: an eval set trimmed to what already passes measures nothing. evals/planner_questions.yaml keeps two questions the planner cannot answer, and the test asserts those refusals as firmly as it asserts any number. Reference SQL and planner SQL are compared as result sets, not first cells — a GROUP BY with no ORDER BY has no first row, and comparing one scored twelve correct breakdowns as wrong.

The regression suite runs keyless in CI, both directly and inside the Docker image. One live-model test skips without an API key. CI also runs the release preflight, UI markup/contrast audit, planner scorecard, and release-pack drift checks. evals/assurance_release.yaml versions the evidence paths, case counts, gates and runtime budgets together; the Trust Center renders that file directly.

The live layer — does the model write SQL that gets the right answer? — is graded by scripts/run_live_eval.py, which asks the assistant every golden and adversarial question, runs the SQL it writes, and scores the results. It needs an API key, so it runs on demand rather than in CI.

Production-minded controls in the prototype

  • Every answer reports its token spend. The schema cost is controlled before the model call: the measured retriever sends about 2,253 schema tokens at its perfect-recall cutoff instead of the full catalogue's 12,741. On a compiled turn it reports no tokens at all, because none were spent.
  • It degrades into a different engine, not into a brochure. With no API key there is no model, so the chat box is answered by the compiler instead — and the interface says which one ran on every single turn. The pipeline strip lights PLAN rather than GENERATE, the grounding panel reports "no prompt and no tokens on this turn" instead of a schema budget it never spent, and the masthead reads keyless · compiled from the schema. Lighting GENERATE for a compiled query would be the one lie this app cannot afford, so the two cells occupy the same position and exactly one of them can be true.
  • Bring your own key. The deployment holds no key — a key on a public URL is an unmetered spend surface — but the sidebar accepts one. It lives in that browser's Streamlit session in server memory: never written by the app, never logged, sent to Anthropic for model calls, and gone when the session expires. That makes the model path reachable without pretending BYOK is browser-local.
  • Questions are deep-linkable. ?q=your+question asks it on load, which is how a result gets shared and how the screenshot above is reproducible rather than something I typed once.
  • You can edit the SQL and run it yourself. This is the part I would defend hardest. The app's argument is the SQL is shown next to the answer so anyone can audit it — and until recently auditing it was all you could do. If you read the query and saw it had picked submitted_amount where you wanted paid_amount, the interface's answer was: leave. Now every answer carries an editor. Nothing is relaxed to allow it: a person is exactly as untrusted as the model and as the compiler, so your query goes through the same validate_sql, the same Verifier and the same 200-row capped executor. Paste DROP TABLE healthcare_fact_claims in and the panel comes back blocked by the guard, with EXECUTE dark, because the boundary was never about who was writing.
  • Results download as CSV, and the sidebar is a schema browser rather than eleven paragraphs of prose: search table names, column names and indexed values, so typing denied finds healthcare_fact_claims through a value in status — the same binding the compiler makes, exposed as something you can browse. Each column shows the role engine/semantics.py inferred for it, which is otherwise invisible anywhere in the app.
  • The evidence is one click down, not always open. Retrieval, the guard, the verifier and the physical plan used to run about 1,400px on every turn, so reading a number meant scrolling past the proof of the number every time. They are the reason to trust the app; they are not the reason to open it. The pipeline strip, the answer, the binding trace and the SQL stay above.
  • The accuracy contract is still there, one expander down: 39 questions whose reference SQL is committed and re-run by CI on every push. It is a different kind of evidence from the chat box and it is now labelled as such, rather than standing in for a product.
  • Conversations are real. The Streamlit app keeps per-session history and renders the full transcript; the shared warehouse is stateless behind it.

The data (all synthetic — no PHI, no real customers, no real employees)

Domain What you can ask about
healthcare Hospital revenue cycle — claims, payers, denials, the NRV worklist
hr Workforce — headcount, attrition, hiring funnel, flight-risk scores
finance GL reconciliation — ERP vs. subledger and the exceptions between them
supplychain Cold-chain distribution — orders, fill rates, inventory lots, forecast
retail Wholesale customers — RFM segments, cross-sell recommendations
migration Legacy-to-Fabric program — moved artifacts and parallel-run validation
marketing Attribution & incrementality — journeys, channel spend, geo experiments
clinical Trial data management — EDC capture, edit-check queries, injected-defect detection
aml Transaction monitoring — scored payments, alert thresholds, case outcomes
wholesale Northgate supercenter chain — department sales, stores, suppliers, labour
dbt The modelled warehouse itself — models, data tests, lineage, KPI mart

Every table was generated with fixed seeds (Faker and friends) in its source repo. data_manifest.py is the single source of truth — domain, source path, and the business description the model reads; scripts/vendor_data.py copies the curated set in.

Run it

pip install --require-hashes -r requirements.lock

# 1. Prove the plumbing — no API key needed
pytest tests/ -v

# 2. Ask questions. With no ANTHROPIC_API_KEY this uses the compiler;
#    with one it uses the model. --plan and --model force either.
python -m app.cli "how many denied claims are there?"
python -m app.cli --plan "what is the average salary by department?"

# 3. The chat UI. The box works with or without a key; the sidebar takes one.
streamlit run app/streamlit_app.py

# Optional cloud voice fallback. You can also paste this key into the sidebar
# for the current Streamlit session only.
export OPENAI_API_KEY=sk-...              # PowerShell: $env:OPENAI_API_KEY="sk-..."
# Optional overrides: ASK_STT_MODEL, ASK_TTS_MODEL, ASK_TTS_VOICE

# 4. Score the compiler on both contracts, and sweep its confidence gate
python scripts/run_planner_eval.py
python scripts/run_planner_eval.py --sweep

# 5. Grade the model end-to-end: accuracy + safety (needs a key)
python scripts/run_live_eval.py

# 6. Reproduce the schema-retrieval recall and token curve
python scripts/run_retrieval_eval.py --sweep

# 7. What the semantic layer inferred about the warehouse
python -m engine.semantics

Defaults to claude-opus-5; set ASK_YOUR_DATA_MODEL to swap models. The compiler has no model to swap.

To keep SQL generation local as well, point the same governed pipeline at an OpenAI-compatible Ollama, llama.cpp, vLLM, or LM Studio server. Explicitly selecting the provider activates model mode without a paid API key:

export ASK_PROVIDER=ollama
export ASK_LOCAL_BASE_URL=http://localhost:11434
export ASK_LOCAL_MODEL=qwen2.5-coder:7b
export ASK_LOCAL_MODE=auto       # tools -> JSON schema -> prompt fallback
python -m app.cli --model "how many denied claims are there?"

The local adapter shares one request deadline across fallbacks and repairs. Provider and mode typos fail closed; they never fall through to a cloud model. Run python scripts/run_live_eval.py against the selected model before treating it as a release candidate—the repository does not claim unmeasured local-model accuracy.

Deploying it, and why the public copy is on Streamlit

The public demo runs on Streamlit Community Cloud, which builds from main on every push. That is a measurement, not a preference. Driven headlessly through a cold start and two questions, the serving process settles at 366 MB resident with a 564 MB peak — the peak lands while the MiniLM ONNX session is being built alongside the in-memory warehouse. A 512 MB container does not survive that spike: it restarts mid-warm-up, and because the HTTP health route answers before the index exists, it reports healthy right up until it doesn't. Streamlit Cloud's per-app ceiling is several times that, so the same build sits inside it with room left over.

The footprint was reduced rather than merely accommodated. engine/vector_index.py builds its ONNX session with the CPU arena and memory-pattern reuse disabled, sequential execution, one thread, and basic graph rewrites only. The corpus is 71 schema documents and inference is one short sequence at a time, so none of what those options buy is reachable here — they were only holding model-sized buffers.

Two configuration notes that matter more than they look:

  • server.allowedHosts is deliberately absent from .streamlit/config.toml. It is an allow-list on the WebSocket Host header, and a value that does not match what the platform's proxy forwards does not degrade — it 403s the socket, and a Streamlit app with a refused socket renders as a permanent "Please wait…" with nothing on the page to explain it. Streamlit's own default is empty for exactly this reason. The control is not dropped; it moves to STREAMLIT_SERVER_ALLOWED_HOSTS in the Dockerfile, which is the deployment that can verify its own hostname. tests/test_streamlit_security_config.py asserts both halves, so a later hardening pass cannot quietly put an unverifiable allow-list back in front of the public link.
  • No secrets are required. ASK_AUTH_MODE defaults to disabled, so a fresh deployment with no environment at all comes up anonymous and keyless, answering from the compiler. Everything below is opt-in.

Dockerfile and render.yaml are kept and CI still builds the image, because a container is what an enterprise deployment actually runs and what the identity and policy controls below are configured through. On a 512 MB free tier, give the image a paid instance or expect the warm-up to be the thing that kills it.

Enterprise identity, policy, and request bounds

The public synthetic demo remains explicitly unauthenticated. A protected deployment enables OIDC and a default-deny role policy:

export ASK_AUTH_MODE=oidc
export ASK_OIDC_ISSUER=https://identity.example.com/
export ASK_OIDC_AUDIENCE=ask-your-data
export ASK_OIDC_JWKS_URL=https://identity.example.com/.well-known/jwks.json
export ASK_POLICY_FILE=/run/secrets/ask-your-data-policy.yaml
export ASK_REQUEST_TIMEOUT_S=45

Start from enterprise-policy.example.yaml. JWT signature, issuer, audience, expiry, issued-at, and subject are verified; unknown roles default to no tables. The schema sent to a model and shown in the browser is filtered to authorized tables and columns. Manual SQL, certified metrics, the compiler, and model SQL all pass through the same policy point in engine/query.py.

This is defense in depth, not a replacement for warehouse RLS/masking. The full target architecture, SLOs, runbook, remaining production evidence, and honest readiness assessment are in docs/ENTERPRISE_ARCHITECTURE.md, docs/SLO_AND_RUNBOOK.md, and docs/ENTERPRISE_READINESS_2026.md.

Voice with no API key, running inside the process

Ask a question out loud and hear the answer back, on the public deployment, with nothing configured. Both halves are open models running in this process — no key, no account, no second service, and the recording and the answer text never leave the server:

model weights why this one
speech → text faster-whisper tiny.en (CTranslate2, int8) ~40 MB see below
text → speech Piper en_US-joe-medium male voice (VITS on onnxruntime, CC0 source dataset) ~63 MB onnxruntime was already a dependency, so this adds a model rather than a runtime

Joe is an enterprise-safe default rather than merely the best-sounding local test. The higher-scoring hfc male voice was rejected because its source dataset is non-commercial; Joe's source dataset is CC0 and its two runtime artifacts are checksum-pinned before download. An unknown ASK_LOCAL_TTS_VOICE value fails closed instead of fetching an unreviewed model and skipping verification.

This replaced a real but unreachable feature. The Compose stack below has always offered free voice through a self-hosted Speaches container, and it still does — but Streamlit Community Cloud runs one process with nowhere to put a second container, so every visitor to the demo saw "Recordings are sent only after voice is enabled". Voice was a feature you had to already be an operator to use.

tiny.en, and the measurement that chose it. Against speech synthesized by the TTS half above, tiny.en and base.en produced identical transcripts — and base.en took 41.8 s to load against 0.9 s. What earns the accuracy is not the model size, it is the decoder prompt: unprompted, tiny.en writes Self-pay, and self pay binds no WHERE clause. Naming Self-Pay in the prompt fixes it.

The elegant version of that prompt does not work, and it was tried three ways. engine/semantics.py already extracts 797 value phrases from the warehouse, and biasing the decoder with them would have been derived rather than hand-written — this repo's whole preference. Ranking them longest-first pulled a 200-character clinical query narrative into the prompt and produced "about 19% of it allowed amount"; bounding the length spent the budget on models staging stg warehousessql; ranking by source-column cardinality, the most principled of the three, filled up on columns holding one distinct value — true, pass, success — and heard "Softpay". The reason is structural: the lexicon is normalised to lower case so the compiler can match it, and case is exactly what the prompt was for. So the vocabulary is curated, which is what DOMAIN_KEYWORDS was always for.

Answers speak themselves. Ask a question and the answer is read aloud as it arrives — no Listen click — on every path: the compiler, the certified metrics, hand-written SQL, and all 39 worked examples. A toggle in the voice panel turns it off, and each answer plays exactly once: the player stays for deliberate replay but never restarts itself on a Streamlit rerun.

What it costs, measured: the resident floor goes from 330 MB to 465 MB with the voice loaded and 611 MB with both. Transcription stays lazy — record nothing and you pay nothing for it — but the voice is loaded up front, on a background thread, in every container that serves a page. That is what makes autoplay work rather than a nicety: a cold first synthesis costs 21.5s against 0.17s warm, and a browser only lets audio start by itself within roughly five seconds of the click that asked for it. Loaded lazily, the first answer of every container missed that window and refused to play, silently, while every later answer worked. Measured after the change, on a cold container: click to playing audio in 1.4s. ASK_VOICE_PREWARM=0 declines the 135 MB.

engine/local_voice.py is optional at import. If the wheels are missing or a model cannot be fetched, available() returns False and the app falls back to the remote seam or reports voice unconfigured — a deployment that cannot install CTranslate2 still answers questions.

Self-hosted voice and optional n8n operations

The included Compose stack runs the app with a versioned CPU Speaches image and a self-hosted n8n instance:

# Both are required; Compose refuses to start with placeholders or empty values.
export ASK_N8N_WEBHOOK_SECRET="$(openssl rand -hex 32)"
export N8N_ENCRYPTION_KEY="$(openssl rand -hex 32)"

docker compose -f compose.local.yml up --build

Open the app at http://localhost:8501, Speaches at http://localhost:8000, and n8n at http://localhost:5678. On first use, Speaches downloads the selected models into its persistent volume. Import automations/n8n/ask-your-data-ops.json in n8n and activate it. Operational events are HMAC-signed and intentionally exclude question text, SQL, result values, refusal reasons, and retry feedback; n8n failure never blocks a query.

n8n is useful here for routing failures or latency alerts to an enterprise destination, but it is not on the synchronous analytics path. It is fair-code/source-available under n8n's Sustainable Use License, not OSI open source. Speaches is MIT-licensed; its local STT and TTS engines are faster-whisper and Kokoro.

Repo layout

data_manifest.py    the catalog: every table's domain, source, and description
data/               vendored synthetic CSVs, by domain
engine/
  warehouse.py      builds the in-memory DuckDB + the schema catalog
  access.py         OIDC principal + default-deny table/column policy
  deadline.py       one monotonic end-to-end request budget
  sql_guard.py      read-only validation — the safety boundary
  query.py          capped, cursor-isolated execution
  retrieval.py      local schema index + measured keyword baseline
  vector_index.py   checksum-pinned MiniLM ONNX + exact cosine search
  semantics.py      the warehouse profiled: roles, grains, joins, value lexicon
  planner.py        the keyless engine: question -> bindings -> SQL, or a refusal
  verify.py         structural checks on SQL, whoever wrote it
  exemplars.py      the few-shot bank, selected by RRF over solved questions
  providers.py      the model seam: Anthropic, or any OpenAI-compatible endpoint
  metrics.py        exact matching + contracts for policy-owned definitions
  voice.py          the speech seam: engine choice, bounds, no alternate query path
  local_voice.py    in-process open STT/TTS - faster-whisper + Piper, no key
  automation.py     bounded, privacy-minimized n8n operational event queue
  assurance.py      versioned release gates, evidence fingerprint, roles and budgets
  assistant.py      NL -> SQL -> self-correction -> grounded answer + telemetry
app/
  cli.py            terminal Q&A — compiler by default, model with a key
  streamlit_app.py  Ask, Data catalog, Trust center, and Project brief workspaces
  ui.py             the instrument panel — every readout in this repo
evals/
  assurance_release.yaml      governed suites, release gates, budgets and scope boundary
  golden_questions.yaml       question -> reference SQL -> expected answer -> the sentence that reports it
  planner_questions.yaml      the compiler's contract, refusals included
  adversarial_questions.yaml  15 model-behavior attacks with named risks and controls
tests/              guard, warehouse, semantics, planner, golden SQL, fake-client harness
metrics.yaml        certified definitions, owners, expected values, schema-only contrasts
scripts/            vendor_data.py, run_planner_eval.py, run_live_eval.py, run_retrieval_eval.py
automations/n8n/     importable HMAC-verified operations workflow
compose.local.yml   app + free local voice + optional workflow automation
Dockerfile          non-root app image with a health check (CI also builds it)
requirements.lock   fully transitive, hash-locked Python environment
enterprise-policy.example.yaml  example role and sensitive-column policy
docs/ASSURANCE_CASE.md  business case, requirements, UAT, rollout and decision rights

Retrieving the schema, and checking it was worth it

The assistant used to paste every table into every prompt. At six domains and 36 tables that cost ~2,738 tokens and was defensible. Adding five more projects took it to 11 domains and 71 tables, and the same block became ~12,741 tokens — so a question about claim denials was paying for the AML and clinical schemas it never reads.

engine/retrieval.py embeds one document per table and pastes only what the question needs. The interesting part is not that it works; it is that the repo had to prove it was better than not doing it. scripts/run_retrieval_eval.py scores three strategies against ground truth parsed out of the reference SQL in evals/golden_questions.yaml — the tables a question needs are the tables its correct answer selects from, so the labels are derived rather than authored.

strategy k questions fully covered tables recalled ~tokens/turn
full catalogue — 100.0% 100.0% 12,741
keyword 10 100.0% 100.0% 2,121
vector 10 94.9% 95.6% 2,371
hybrid (RRF) 10 100.0% 100.0% 2,253

Hybrid is reciprocal-rank fusion of the other two, and it exists because each fails where the other succeeds. "Who is the top wholesale customer by revenue?" ranks retail_customer_analytics 17th by embedding and 3rd by keyword — the word "wholesale" drags the vector into the wrong domain, while the literal token match does not care about aboutness. RRF reads only the ranks, because cosine similarity and integer token overlap have no common scale and normalising them would invent one.

Vector-only recall plateaus at 94.9% because two questions are ranking failures, not context-budget failures. Keyword retrieval catches both, so the shipped hybrid reaches 100% table and question recall on the committed 39-question set at k=10. This is a regression contract over that set, not a claim that arbitrary future questions have perfect retrieval.

The embedding model is all-MiniLM-L6-v2 running through the app's checksum-pinned local ONNX implementation, so retrieval — and its evaluation — need no API key.


What I deliberately didn't build

The point of a portfolio project is as much the restraint as the features:

  • No vector search over business data. The local index contains 71 schema descriptions and nothing else — the one place semantic matching measurably complements keyword overlap. Every business value still comes from inspectable SQL over DuckDB; embeddings never retrieve claims, employees, customers, or financial rows.
  • No agent framework. The whole loop is ~80 lines you can read: one call to write SQL, one to summarize, a bounded retry. A framework would add layers to audit without adding capability.
  • No unbounded agent. Two retries, then an honest failure. Cost stays predictable and the behavior stays testable — the retry loop is proven in CI with a fake client, not trusted on vibes.
  • No fine-tuning. Schema grounding plus golden-question evaluation beats a fine-tune at this scale, and every part of it is inspectable.
  • No catch-all, untested metric layer. The compiler still gets roles, grains, joins and values from DuckDB and is graded with the registry switched off. metrics.yaml contains only definitions carrying a convention the schema cannot state, and each one has an owner, expected value, live CI test, and the schema-only result shown beside it. Matching is exact and conservative: “denial rate” can use the definition; “denial rate by payer” cannot silently lose the breakdown and therefore falls back to the ordinary engine.
  • No synonym dictionary. One 26-entry map covers words a business user says that no schema ever spells — revenue, headcount. Everything else is derived, because a growing synonym file is how a compiler starts passing its own eval without getting better.
  • No real data. The interface is the demonstration; nobody's records are.

The first voice assistant answered "what's the weather?" by calling an API. Its successor can hear "what's our denial rate?", show the transcript, run a certified definition through a guard, display the SQL and rows, then read the verified result aloud. Same repo. Better question — and now a provable answer.

About

Governed text-to-SQL over 71 synthetic tables: deterministic or model-assisted SQL, read-only structural controls, question-binding trace, returned-row verification, and voice/text answers.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages