text-to-sql-agent-langgraph

Text-to-SQL Dashboard

Ask questions about a real database in plain English and get back validated, read-only SQL, a results table, and an auto-picked chart — powered by a fully local LLM stack (Ollama) and an explicit, self-correcting LangGraph state machine rather than a black-box agent.

Why I built this: most “chat with your database” demos either trust the LLM’s SQL blindly or hide the reasoning behind an opaque agent loop. I wanted to build the version that treats LLM output as untrusted by construction — every query is parsed and allowlisted before it can run, every retry is visible and inspectable, and the schema the model sees scales to a database with hundreds of tables instead of assuming a toy 5-table sample. It’s also a fully local stack (Ollama + ChromaDB, no API keys, no data leaving the machine), which matters for anyone who can’t send a real schema or query results to a hosted API — this holds for the core SQL pipeline unconditionally; the optional multi-source router’s web search feature (off by default) is the one deliberate exception, since a live web search inherently needs to leave the machine — see “Multi-source knowledge” below.

Architecture

flowchart TD
    U[User] --> ENTRY{"Streamlit UI<br/>or REST API"}
    ENTRY --> SI["sanitize_input<br/>length cap, Unicode normalization,<br/>prompt-injection pre-filter"]
    SI -->|rejected| STOP1(["Rejected"])
    SI --> CF["classify_followup<br/>standalone / follow-up / ambiguous"]
    CF -->|ambiguous| STOP2(["Needs clarification"])
    CF --> RS["retrieve_schema<br/>ChromaDB top-k + FK-adjacency<br/>bridge expansion"]
    RS --> GS["generate_sql<br/>Ollama, via LangGraph"]
    GS -->|off-topic / LLM error / rate limit| STOP3(["Rejected / Failed / Rate limited"])
    GS --> VS["validate_sql<br/>sqlglot AST allowlist"]
    VS -->|retryable mistake| GS
    VS -->|safety violation| STOP4(["Failed closed<br/>(security gate, no retry)"])
    VS -->|valid| CE["estimate_cost<br/>non-executing EXPLAIN / SHOWPLAN"]
    CE -->|high cost, retryable| GS
    CE -->|low/moderate| ES["execute_sql<br/>read-only engine, row cap, timeout"]
    ES -->|unknown table/column| RS
    ES -->|other error, retries left| GS
    ES -->|timeout| STOP4
    ES -->|success| GI["generate_insight<br/>optional, grounded summary"]
    GI --> REVIEW["Show SQL + cost notice<br/>for review"]
    REVIEW -->|Confirm and Run| RUN[Re-validate + re-execute]
    RUN --> RESULTS[Results table, chart, insight]

Retries are capped (MAX_RETRIES, default 3) and every attempt is recorded and shown in the UI’s “Retry timeline” — the self-correction loop is meant to be inspectable, not a black box. See docs/ARCHITECTURE.md for the full technical walkthrough (all eight LangGraph nodes, retry semantics, schema-retrieval internals) and USER_GUIDE.md for what this looks like from inside the app.

Optional: this SQL pipeline is the default destination of a multi-source router, off by default (ENABLE_MULTI_SOURCE_ROUTER=false). Turned on, a question can also be routed to (or fanned out across) uploaded PDF documents, a separate sensitivity-gated company-policy collection, or live web search — see “Multi-source knowledge” below. With the router off, this diagram is the entire app, unchanged from before that feature existed.

Key features

Tech stack

Layer Choice Why
LLM runtime Ollama (llama3.1:8b default) Fully local — no API keys, no data leaves the machine, no per-token cost while iterating.
Orchestration LangGraph An explicit state machine (not a free-form ReAct agent) — the retry/error-feedback path is a fixed, inspectable graph, not implicit agent reasoning.
Schema retrieval ChromaDB Local vector store; keeps the prompt small and relevant on a schema with hundreds of tables instead of dumping everything into context.
Database SQLAlchemy One engine abstraction across 4 supported databases, config-driven, with a real Inspector-based introspection API instead of per-engine catalog queries.
SQL validation sqlglot Parses the AST and allowlists the statement type — can’t be bypassed by a syntax variant the way a keyword blocklist can.
UI Streamlit + Plotly Fast to build a real reviewable UI (editable SQL box, retry timeline, schema browser) without a separate frontend.
API FastAPI A thin, optional REST surface (api/) over the same agent graph the UI calls — see docs/API.md.
Multi-source router LangGraph (a second graph, agent/orchestrator/) Optional, off by default — routes to/fans out across the SQL pipeline, document/policy RAG, and web search. See docs/MULTI_SOURCE_GUIDE.md.
Document/policy RAG storage SQL Server 2025+ / Azure SQL native VECTOR type A dedicated connection, separate from DB_CONNECTIONS — see rag/store.py.
Web search Tavily (configurable provider) The one exception to this project’s fully-local posture — only when explicitly enabled.
Deployment Docker + Compose Non-root, pinned, health-checked containers for the UI and API — see docs/DEPLOYMENT.md.

Supported LLM models

Ollama is the only supported LLM runtime — there is no hosted-API code path (no OpenAI/Anthropic/etc. client anywhere in the codebase). Any model ollama pull-able works; OLLAMA_MODEL is a plain config string (config/settings.py), not a hardcoded value:

Model Notes
llama3.1:8b (default) What this project is built and benchmarked against — see docs/EVALUATION.md for measured accuracy.
sqlcoder, duckdb-nsql, or any other Ollama-hosted model Untested by this project’s own benchmark as of this writing, but supported by the same config knob — swap OLLAMA_MODEL and re-run python scripts/run_benchmark.py to measure it yourself.

Supported databases

DB_TYPE Driver Notes
postgresql psycopg2-binary  
mysql pymysql  
mssql pyodbc Requires the Microsoft ODBC Driver for SQL Server installed as a system package — see docs/TROUBLESHOOTING.md.
oracle oracledb (thin mode) No separate Oracle Client install needed.

Selected via DB_TYPE in .env; see db/connection.py::SUPPORTED_DB_TYPES for the single source of truth this table is drawn from, and docs/CONFIGURATION.md for every connection variable.

Multiple databases: set DB_CONNECTIONS=name1,name2,... (plus a DB_<NAME>_* block per name) instead of the single DB_* block above to connect to more than one database at once — each can be a different DB_TYPE. Each question is then auto-routed to whichever configured database looks relevant (embeddings/retriever.py::select_database); there is no manual database picker in the UI. See docs/CONFIGURATION.md and .env.example for the exact variable shape.

Multi-source knowledge (optional)

Beyond the SQL database(s), a question can also be routed to (or fanned out across) three more sources — all off by default:

When more than one source is relevant to a question, an LLM router picks which (a bounded, well-tested single LLM call — see docs/ARCHITECTURE.md), and the answer attributes each source’s contribution under its own labeled heading rather than blending them together. Document/policy chunks are stored using SQL Server 2025+/Azure SQL’s native VECTOR column type, on a connection kept deliberately separate from your business database(s).

Full setup walkthrough, including exactly where the Tavily API key goes and how to upload a policy document: docs/MULTI_SOURCE_GUIDE.md.

Project structure

Gen_AI_Project_TSQL/
├── agent/            # LangGraph nodes, state, SQL validator, LLM client, rate limiting
│   └── orchestrator/  # Optional multi-source router (off by default) -- sits in front of agent/graph.py
├── api/               # Optional FastAPI REST layer (thin wrapper over agent.orchestrator.graph.run_orchestrated)
├── config/            # Settings (env-driven), table descriptions, sensitive-column classification
├── db/                 # SQLAlchemy engine, schema introspection, query execution, cost estimation
├── docs/              # Architecture, security, deployment, API, evaluation, governance, multi-source docs
├── embeddings/        # Chroma index build + top-k/FK-adjacency schema retrieval
├── eval/               # Text-to-SQL benchmark harness: dataset, evaluators, metrics, regression
│   └── benchmark/      # Benchmark case YAML files (easy/medium/hard/real_world/adversarial/...)
├── observability/      # LLM call timing capture, result-log redaction
├── rag/                # Optional document/policy agentic RAG (SQL Server native VECTOR storage)
├── scripts/            # CLI entry points: build_embeddings, test_db_connection, run_benchmark, ...
├── search/             # Optional live web search (provider-configurable, Tavily implemented)
├── security/           # Secret redaction, SecretStr, audit logging, sanitization, injection patterns
├── tests/              # Fully mocked pytest suite (no live DB/Ollama required)
├── ui/                 # Streamlit app (the primary interface) + session history + column formatting
│   └── pages/           # Knowledge Sources page -- PDF upload/management for document/policy RAG
├── Dockerfile, docker-compose.yml, .dockerignore
├── requirements.txt, pyproject.toml
├── tasks.ps1, Makefile
└── README.md, SECURITY.md, CONTRIBUTING.md, USER_GUIDE.md

The two things worth knowing before browsing further: agent/graph.py wires everything in agent/nodes.py into the state machine described above, and ui/app.py/api/main.py are both thin — neither contains agent logic, they only call agent.graph.run_agent. See docs/ARCHITECTURE.md for what each module is responsible for in detail.

Setup

Prerequisites

Clone and install

macOS / Linux / WSL (bash):

git clone <this-repo-url>
cd Gen_AI_Project_TSQL
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
cp .env.example .env

Windows (PowerShell):

git clone <this-repo-url>
cd Gen_AI_Project_TSQL
py -3.11 -m venv .venv      # or: python -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
pip install -r requirements.txt
Copy-Item .env.example .env

Then edit .env with your real database connection details (every variable is documented inline in .env.example) — use a dedicated read-only database account, not an admin login (see SECURITY.md).

Verify the connection before doing anything else:

python scripts/test_db_connection.py

Trying it against a sample database

This project was built and tested against Microsoft’s public AdventureWorksDW2025 sample data warehouse (SQL Server). If you don’t have a database handy to point this at, you can download and restore it from Microsoft’s official samples page: learn.microsoft.com/en-us/sql/samples/adventureworks-install-configure — any edition of SQL Server (including the free LocalDB/Express editions) works.

How to run

Build the schema embeddings once (and again any time the schema changes — this is also available from the UI’s “Refresh Schema” button):

python scripts/build_embeddings.py

Run the app:

streamlit run ui/app.py

Run the Text-to-SQL benchmark (a live-DB + live-Ollama check, separate from the mocked pytest suite — see CONTRIBUTING.md for how to add cases to it):

python scripts/run_benchmark.py                   # full dataset
python scripts/run_benchmark.py --limit 20         # a quick, smaller run
python scripts/run_benchmark.py --check-regression # compare against the stored baseline

Run the mocked unit test suite + linters:

pytest
ruff check . && black --check . && mypy .

PowerShell equivalents for all of the above are in tasks.ps1 (.\tasks.ps1 run, .\tasks.ps1 test, .\tasks.ps1 lint); Make targets for bash are in the Makefile.

Running with Docker

cp .env.example .env   # then edit .env as above
docker compose build
docker compose up -d
docker compose exec app python scripts/build_embeddings.py

UI at http://localhost:8501, API at http://localhost:8000. See docs/DEPLOYMENT.md for connecting containers to a host-run Ollama, an external database, reverse-proxy/auth placement, and what’s deliberately not included (Kubernetes, a bundled database/LLM).

Screenshots

TODO: add a screenshot of the chat + generated-SQL view, and one of a results table with an auto-picked chart.

Known limitations

More documentation

License

MIT — see LICENSE for the full text.