Author: Anamala Girisha · Status: v1 implemented · Last updated: 2026-10-15 Related:
docs/PLAN.md,docs/DESIGN.md,docs/eval-results.md
A business user wants to ask an e-commerce database questions in plain English:
"How many new customers signed up last month?"
Generating SQL for that is easy. The hard part is that many business questions are underspecified:
"Show me last month's best customer."
"Best" can mean highest spend, most orders, or most frequent return visits. All three give different customers. A typical text-to-SQL demo picks one reading silently and returns a confident, wrong answer. The user cannot tell it guessed.
Build a natural-language query layer that:
- Detects when a question has more than one reasonable reading that changes the answer.
- Asks the user which reading they mean, with concrete options, before writing SQL.
- Generates read-only PostgreSQL, runs it safely, and returns the rows plus the assumptions it made.
- Measures, on a fixed test set, how many questions are answered correctly with the clarification engine versus without it.
| Metric | Target |
|---|---|
| Execution accuracy on ambiguous questions, engine vs. direct | Engine clearly higher (the point of the project) |
| Execution accuracy on clear questions | Engine ≥ direct (asking must not hurt) |
| False clarification rate (asked on a clear question) | ≤ 10% |
| Clarification recall (asked on an ambiguous question) | ≥ 90% |
| Cost | ₹0 — runs fully offline by default; free-tier LLM optional |
- Business user (primary): founder, marketer, ops lead. Knows the business, not SQL.
- Analyst (secondary): wants to see the SQL behind an answer and trust it.
POST /api/askendpoint with a two-turn clarification flow (stateless).- A business glossary (semantic layer) that holds:
- Ambiguous terms — must be clarified (e.g. "best customer").
- Governed defaults — terms with one company-approved meaning that are applied and stated, not asked (e.g. "revenue excludes canceled orders").
- Two SQL generators behind one interface:
- Offline planner (default, no network, ₹0): deterministic intent → SQL templates.
- LLM (optional): Groq or Gemini free tier, JSON structured output validated by Pydantic.
- SQL safety guard + read-only, time-limited execution.
- Demo dataset (customers, orders, order_items, products, payments) in a separate
Postgres schema
ask_demo, seeded deterministically. - Evaluation harness: 30 labelled questions, simulated user, execution-match scoring,
results written to
docs/eval-results.md. - Minimal React page
/askto try the flow.
- Multi-turn chat memory beyond one clarification round.
- Charts / visualisation of results.
- Per-user database roles and row-level security (see §10).
- Writing to the database in any form.
- Ingesting arbitrary uploaded datasets (the schema is fixed to the five demo tables).
User question
│
▼
Glossary scan ──► ambiguous terms found? ──┐
│ │ (LLM mode: LLM may add more)
▼ no ▼ yes
Generate SQL Return needs_clarification
│ { term, prompt, options[] }
▼ │
SQL guard User picks options
│ │
▼ ▼
Read-only execution ◄──────────── Re-send question + clarifications
│
▼
Answer { rows, sql, assumptions, interpretation }
Q: Show me last month's best customer.
A: needs_clarification
"What does 'best customer' mean here?"
( ) Highest spend — total value of non-canceled orders
( ) Most orders — number of non-canceled orders placed
( ) Came back most — number of distinct days with an order
Q: (same) + best_customer = most_orders
A: answered
customer_id orders
C-MANY 14
Assumptions:
• "last month" = previous calendar month (2026-09-01 to 2026-09-30)
• Canceled orders are excluded
POST /api/ask
Request:
{
"question": "Show me last month's best customer.",
"mode": "engine",
"clarifications": [
{"term": "best_customer", "option_id": "orders", "definition": "Most orders placed"}
]
}mode:"engine"(default, clarify first) or"direct"(never ask — baseline).clarifications: the options the user picked in the previous turn.definitionlets LLM-proposed options round-trip without server state.
Response — needs clarification:
{
"status": "needs_clarification",
"question": "...",
"clarifications": [
{"term": "best_customer", "prompt": "What does 'best customer' mean here?",
"source": "glossary",
"options": [{"id": "revenue", "label": "Highest spend", "definition": "..."}]}
]
}Response — answered:
{
"status": "answered",
"question": "...",
"sql": "SELECT ...",
"columns": ["customer_id", "orders"],
"rows": [["C-MANY", 14]],
"row_count": 1,
"truncated": false,
"assumptions": ["..."],
"interpretation": {"best_customer": "orders"},
"generator": "offline"
}Response — cannot answer: {"status": "unsupported" | "rejected" | "error", "detail": "..."}
(HTTP 200 for these so the UI can show the message; 422 for malformed requests.)
| Term | Meaning in this feature |
|---|---|
| Ambiguous term | A phrase with ≥2 plausible readings that give different results and no company default. Must be clarified. |
| Governed default | A phrase with one approved meaning. Applied silently and listed in assumptions. |
| Interpretation | The map term → chosen option used to build the SQL. |
| As-of date | "Today" for relative dates. ASK_AS_OF_DATE env, else the real date. |
| Valid order | An order whose status is not canceled. |
| Execution match | Generated SQL's result equals the gold SQL's result (§9). |
| Term id | Triggers on | Options |
|---|---|---|
best_customer |
best/top/number one/most valuable customer, buyer, shopper | revenue · orders · frequency (distinct order days) |
best_product |
best/top/best-selling/most popular product, item | revenue · units |
best_category |
best/top category, category performed best | revenue · units |
new_customers |
new customers (unless "signed up/registered/joined") | signup date in period · first order in period |
recent |
recent, recently, lately (with no explicit period) | last 7 · 30 · 90 days |
large_order |
big/large/huge/high-value orders | value > ₹1,000 · top 10% by value |
active_customers |
active customers (with no explicit period) | ordered in last 30 · 90 · 365 days |
A term is not asked when the question already disambiguates it (e.g. "top 5 customers by total spend", "new customers who signed up").
- Revenue = sum of
order_valueof valid orders. - Canceled orders are excluded from every metric unless the question is about cancellations.
- "Last month" = previous calendar month. "This month/year" = start of period to as-of date.
- "Last N days" = the N full days before the as-of date.
- No period mentioned = all time.
backend/app/
├── api/ask.py POST /api/ask
└── ask/
├── config.py AskSettings (env: ASK_*)
├── schemas.py Pydantic request/response + LLM structured outputs
├── glossary.py ambiguous terms + governed defaults + detection
├── periods.py "last month" → [start, end) dates
├── planner.py offline generator: intent → SQL template
├── llm.py Groq / Gemini JSON clients (httpx), schema-validated
├── sql_guard.py single SELECT/WITH only, denylist
├── executor.py read-only txn, statement_timeout, search_path, row cap
└── engine.py orchestration: detect → clarify → generate → guard → run
sql/schema.sql demo schema DDL (ask_demo)
scripts/seed_ask_demo.py deterministic demo data
scripts/eval_ask.py evaluation harness → docs/eval-results.md
eval/cases.json 30 labelled questions + gold SQL
frontend/src/pages/Ask.tsx UI
- Glossary first, LLM second. Known business ambiguities live in a reviewed glossary, the same idea as a dbt/Cube semantic layer. Detection is deterministic, testable and free. The LLM only adds ambiguities the glossary misses. Reason: an LLM asked "is this ambiguous?" is inconsistent run to run; a company's metric definitions should not depend on sampling temperature.
- Governed defaults instead of asking everything. Asking about "revenue" on every question makes the tool unusable. Terms with an approved definition are applied and shown as assumptions.
- Stateless clarification. The client re-sends the question plus chosen options. No session store, nothing to expire.
- Offline generator is the default. The project must cost ₹0, run without paid APIs and work fully locally. Groq/Gemini free tiers are opt-in via env.
- Read-only by construction. Guard rejects anything but one SELECT/WITH; the
executor also runs inside
SET TRANSACTION READ ONLYwith a 5 s timeout and a row cap, then rolls back. The guard is a convenience; the transaction is the control. - Separate
ask_demoschema. Demo data lives in its own schema so the same database can hold real data later; pointASK_DB_SCHEMAat another schema with the same tables. - Fair baseline. "Without clarification" (
mode=direct) uses the same glossary defaults and the same generator; it only skips the asking step and lets the generator choose. This isolates the effect of clarification.
- 30 questions: 16 clear, 14 ambiguous. Several ambiguous questions appear more than once with different intended meanings — exactly the case where a single guess cannot be right for every user.
- Each case stores the intended interpretation (what the simulated user picks when asked) and a hand-written gold SQL.
- Execution match: run gold and generated SQL; correct when row counts match and every gold column's values (as a multiset, numbers rounded to 2 dp) appear as some column of the generated result. Extra columns are allowed.
- Sanity check: for each ambiguous case, the harness confirms the other readings really give different results, so the case is discriminating.
- Reported: accuracy (overall / ambiguous / clear) for direct vs. engine, clarification recall, false clarification rate, average questions asked.
- Run:
python scripts/eval_ask.py(offline) or--provider groq|gemini.
Caveat, stated plainly: in offline mode both arms use the same rule-based planner, so offline numbers measure the clarification policy, not language understanding. The LLM run is the real experiment.
| Risk | Mitigation |
|---|---|
| LLM writes destructive SQL | Guard + read-only transaction + timeout + rollback |
LLM reads other schemas (public, pg_catalog) |
Acceptable for a local single-user tool. Before multi-user: dedicated read-only role with USAGE on ask_demo only |
| Over-asking annoys users | Governed defaults; skip when the question already disambiguates; false-clarification rate tracked |
| Glossary misses a phrasing | LLM mode adds ambiguities; eval includes paraphrases |
| Free-tier rate limits / model renames | Model name is configurable (ASK_LLM_MODEL); offline fallback always works |
| Eval overfits to the glossary | Gold SQL written independently of planner templates; caveat in §9 |
- Should a user's clarification be remembered as their personal default ("for me, best customer always means revenue")? Not in v1.
- Should the glossary move to a YAML file editable by non-developers? Python for now; move when someone other than a developer needs to edit it.