Lab 07 — Sales-Data Copilot (Text-to-SQL) — AI Infrastructure for Real Business Problems
Last updated 10/17/2026
Course › Hands-on track › Lab 07

Lab 07 — Sales-Data Copilot (Text-to-SQL)

銷售數據助理(自然語言轉SQL) · Hands-on track: AI Infrastructure for Real Business Problems

Code: labs/07/ · Uses: data/orders.csv (288), order_items.csv (412), products.csv (30), customers.csv (212)

Business Problem — 商業問題

The owner asks the same questions every Monday:

Getting the answers means exporting CSVs and building pivot tables. A copilot that answers in plain language would save hours, but a wrong number delivered confidently is worse than no answer. Real risks:

So the copilot must use agreed business definitions, be physically unable to modify data, show its SQL, and flag answers it can't verify.

Architecture — 系統架構

flowchart LR
    Q[Question] --> P[Prompt = schema + semantic.yaml<br/>metric definitions + as-of date]
    P --> L1[LLM → JSON: sql, assumptions]
    L1 --> G{sqlguard.py<br/>sqlglot AST check}
    G -- unsafe / parse error --> FB[error fed back<br/>≤ 2 fixes] --> L1
    G -- ok + LIMIT --> DB[(DuckDB read_only<br/>external access OFF<br/>timeout)]
    DB -- binder error --> FB
    DB --> V[verify: rows? ranges?<br/>independent re-query agrees?]
    V --> L2[LLM narrative<br/>numbers copied from table]
    L2 --> NC{every number in summary<br/>appears in result?}
    NC --> OUT[answer + SQL + rows + checks + verified flag]

Five layers of safety, from cheapest to most expensive:

  1. Read-only database: duckdb.connect(read_only=True) plus SET enable_external_access = false, so no file or HTTP reads.
  2. AST guard (sqlguard.py, sqlglot):
    • exactly one statement, which must be a SELECT (CTEs and UNION are allowed)
    • no DDL/DML/PRAGMA/ATTACH/COPY/INSTALL
    • no file functions (read_csv, glob …)
    • only the 4 allowed tables
    • a LIMIT is added if missing
  3. Timeout: SQL_TIMEOUT_S interrupts runaway queries.
  4. Semantic layer (semantic.yaml): net_revenue, aov, return_rate, repeat_customer_rate … are defined once by the business, in git. Ambiguity is the main source of wrong analytics, not SQL syntax.
  5. Verification:
    • sanity checks (rows returned, no negative revenue, percentages between 0 and 100)
    • a self-consistency re-query: the model writes the query a different way, and both results must match
    • a number check on the narrative

verified: false doesn't mean wrong. It means "a human should look before this goes in a board deck."

Prerequisites — 先決條件

Step-by-Step Setup — 逐步設定

cd hands-on/labs/07
uv venv --python 3.12 && source .venv/bin/activate
uv pip install -e ../common -r requirements.txt
cp .env.example .env               # AS_OF_DATE=2026-10-15 matches the sample data
uvicorn copilot.main:app --port 8007   # builds var/kk.duckdb from the CSVs on first start

Ask:

ask() { curl -s localhost:8007/v1/ask -H 'content-type: application/json' -d "{\"question\":\"$1\"}" | python -m json.tool; }
ask "What is the average order value by sales channel?"
ask "Top 5 SKUs by units sold in the last 90 days"
ask "Delete all cancelled orders"          # → guard refuses; the DB is read-only anyway

Pass "cross_check": false to skip the re-query (faster, less safe). Run the golden eval:

python eval_golden.py              # compares copilot results with reference SQL in golden.yaml

golden.yaml holds 8 questions with hand-written reference SQL:

A question counts as correct when the copilot's result set equals the reference result set.

How to Test — 測試方法

LLM_PROVIDER=mock pytest -q        # 14 tests

The tests cover:

What was run on the build box — 實測紀錄

Cost & Performance Notes — 成本與效能

Common Failures & Fixes — 常見錯誤與修正

Symptom Cause Fix
Binder Error: column "revenue" not found model guessed a column error is fed back automatically; improve table descriptions in semantic.yaml
"Unknown or forbidden table" model invented a table expected; the allowed list is in the prompt and the guard
Gross vs net confusion undefined metric define it in semantic.yaml and reference it by name in questions
Relative dates wrong ("last month") model uses now() on old sample data AS_OF_DATE is in the prompt; the semantic layer defines "last N days"
Request hangs runaway generation or heavy query max_tokens + SQL_TIMEOUT_S
verified: false often cross-check disagreements look at checks[].alt_sql; often the question is ambiguous, so rephrase or define the metric

Stretch Exercise — 延伸練習

  1. Add GET /v1/metrics/{name} that runs a pre-written SQL for each metric in semantic.yaml (no LLM). Then make the copilot prefer it when the question maps to a known metric. Measure accuracy before and after.
  2. Add 4 golden questions from your own business (with reference SQL) and show accuracy for two models side by side.

Deliverable Checklist — 交付清單

↑