A full-stack Text-to-SQL application built around LangGraph.
The complete component, request-flow, dataset-isolation, security, and deployment diagrams are in ARCHITECTURE.md.
- Docker Desktop (recommended for local setup)
- Python 3.11+ for manual backend development
- Node.js 20+ for manual frontend development
- A Groq or OpenRouter API key
- Optional LangSmith API key
For the step-by-step application workflow, see USER_GUIDE.md. It covers Google sign-in, multi-format uploads, selecting multiple datasets, querying, reviewing SQL, exporting results, and troubleshooting.
Each question follows a controlled pipeline:
- The user signs in with Google. The frontend stores the Google ID token in browser local storage and sends it as a Bearer token to protected API routes.
- The question is normalized and the schema retriever searches the Chroma collection for relevant tables, columns, and relationships.
- The configured LLM generates PostgreSQL SQL and metadata.
- The deterministic validator checks the statement before it reaches
PostgreSQL. It rejects write operations, comments, multiple statements,
unknown tables or columns, and unrestricted
SELECT *queries. - If validation or execution fails, the graph can ask the LLM to repair the
SQL up to
MAX_SQL_RETRIEStimes. - The executor returns at most
MAX_RESULT_ROWSrows. The LLM turns the result into a natural-language answer, while the frontend renders the SQL, result table, export action, and numeric analytics.
The normal query endpoint waits for the complete graph run. The streaming endpoint sends Server-Sent Events (SSE) for graph progress and then a final result event, which the frontend uses to display pipeline progress.
- Natural-language questions translated into read-only SQL
- Schema retrieval with Chroma and LangGraph orchestration
- Multi-format, multi-dataset upload and selection
- Bounded previews for authenticated user datasets
- Editable SQL preview with copy and execute actions
- Deterministic SQL validation before every execution
- Automatic result tables and numeric bar visualizations
- Personal dashboard with user-scoped query history and usage statistics
- Selectable numeric analytics with trend and comparison visualizations
- Local query history with load, remove, clear, and rerun support
- CSV export for query results
- Optional DeepEval/OpenAI-compatible judge configuration for evaluation
- Structured query planning before SQL generation
- Schema-aware plan validation for missing tables and columns
- Execution-result verification for semantically incorrect but executable SQL
- Bounded self-correction with plan repair or SQL repair feedback
Google authentication is required for queries, schema access, SQL execution,
and dataset management. The backend verifies the ID token against
GOOGLE_CLIENT_ID; it does not create a separate application account or issue
its own session token.
For local development:
- Create a Google OAuth web client in Google Cloud Console.
- Add
http://localhost:3000to the authorized JavaScript origins. - Set the client ID in both the root
.envandfrontend/.env.local. - Optionally create a reCAPTCHA v2 checkbox site key and secret. If the backend secret is empty, reCAPTCHA verification is disabled.
The frontend variables are:
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your_google_client_idThe backend requires:
GOOGLE_CLIENT_ID=your_google_client_idIf Google authentication is not configured, /api/auth/google returns HTTP
503 and protected routes return HTTP 401 when no valid Bearer token is sent.
Docker Compose runs PostgreSQL, the FastAPI backend, and the Next.js frontend together. From the project root, make sure Docker Desktop is running and run:
docker compose up --buildCreate a root .env file before starting the stack. At minimum it must contain
an LLM provider and API key:
LLM_PROVIDER=groq
GROQ_API_KEY=your_groq_api_key
GOOGLE_CLIENT_ID=your_google_client_idOpen the application at http://localhost:3000. The backend is available at http://localhost:8000, and its readiness endpoint is:
http://localhost:8000/health
On the first start, Compose waits for PostgreSQL, seeds the demo tables, and
builds the local Chroma schema index before starting Uvicorn. The embedding
model may take a few minutes to download. Wait until the logs show both
Database ready. and Ingested ... schema documents. before using the
frontend. Then refresh the browser if it was opened earlier.
To stop the services, press Ctrl+C. To run them in the background:
docker compose up --build -d
docker compose logs -fThe remaining sections describe manual development, dataset usage, and other project commands.
The backend reads environment variables from the root .env during manual
development and from the container environment in Docker or Render.
| Variable | Default | Purpose |
|---|---|---|
LLM_PROVIDER |
groq |
groq or openrouter |
GROQ_API_KEY |
empty | Groq credential |
GROQ_MODEL |
llama-3.3-70b-versatile |
Groq model name |
OPENROUTER_API_KEY |
empty | OpenRouter credential |
OPENROUTER_MODEL |
meta-llama/llama-3.3-70b-instruct |
OpenRouter model name |
DATABASE_URL |
local PostgreSQL URL | SQLAlchemy PostgreSQL connection |
GOOGLE_CLIENT_ID |
empty | Google OAuth client used by token verification |
CORS_ORIGINS |
local and project origins | Comma-separated browser origins |
MAX_SQL_RETRIES |
1 |
Number of SQL repair attempts |
MAX_CORRECTION_ATTEMPTS |
1 |
Maximum bounded plan/semantic correction attempts |
MAX_RESULT_ROWS |
100 |
Maximum rows returned to the API |
EMBEDDING_PROVIDER |
huggingface |
Use hash for lightweight startup |
EMBEDDING_MODEL |
sentence-transformers/all-MiniLM-L6-v2 |
Embedding model |
CHROMA_PATH |
../chroma |
Chroma persistence path |
LANGSMITH_TRACING |
true |
Enable LangSmith tracing |
LANGSMITH_API_KEY |
empty | LangSmith credential |
LANGSMITH_PROJECT |
text-to-sql-agent |
LangSmith project name |
For local PostgreSQL, the default Docker Compose credentials are texttosql
for the database, user, and password. To change them, set POSTGRES_DB,
POSTGRES_USER, and POSTGRES_PASSWORD in the root .env, and keep
DATABASE_URL synchronized with those values.
Deploy the backend first so you have its public URL for the frontend.
This repository includes render.yaml for a Render Blueprint deployment.
-
Push the repository to GitHub.
-
In Render, choose New + and then Blueprint.
-
Select the GitHub repository and apply
render.yaml. -
Set
GROQ_API_KEYwhen Render asks for the secret value. -
Set
GOOGLE_CLIENT_IDwhen Render asks for the authentication secret. -
Set
CORS_ORIGINSto the Vercel URL you will use, for example:https://your-project.vercel.app -
Wait for the service health check at
/healthto pass.
The Blueprint creates the Render web service. Enter your Neon pooled connection
URL in the DATABASE_URL field when Render prompts for it. The application
already uses SQLAlchemy with the psycopg driver, so the Neon URL must start
with postgresql+psycopg://:
postgresql+psycopg://neondb_owner:password@your-pooled-host.neon.tech/neondb?sslmode=require&channel_binding=require
Copy the URL from Neon with the password visible, then change only the URL
scheme from postgresql:// to postgresql+psycopg://. Do not commit the URL
or expose the password in GitHub.
The backend binds to Render's assigned PORT immediately, then seeds the
database and builds the Chroma schema index in the background. The first deploy
can take a few minutes while the embedding model downloads. Wait for the logs
to show Ingested ... schema documents. before sending Text-to-SQL queries.
Copy the deployed backend URL, for example:
https://text-to-sql-backend.onrender.com
Verify it in a browser:
https://text-to-sql-backend.onrender.com/health
-
In Vercel, choose Add New Project and import the same GitHub repository.
-
Set Root Directory to
frontend. -
Leave the framework as Next.js.
-
Add this production environment variable:
NEXT_PUBLIC_API_URL=https://text-to-sql-backend.onrender.comAlso add
NEXT_PUBLIC_GOOGLE_CLIENT_IDin Vercel. -
Deploy the project.
After Vercel gives you the production URL, update the Render
CORS_ORIGINS value to that exact URL and redeploy or restart the Render
service. Do not add a trailing slash.
- Keep
GROQ_API_KEY,OPENROUTER_API_KEY, and database credentials in the Render or Vercel environment settings, never in Git. - The Render backend can use the free web-service tier. A free PostgreSQL provider may be separate from Render; confirm its current limits and sleep or expiration policy before using it for production data.
- Render's free service may sleep when idle, so the first request can be slow.
- Render's local filesystem is not permanent on redeploys. Uploaded CSV files should be treated as temporary unless a persistent Render disk or external storage is configured.
- If the frontend shows
Failed to fetch, verify the VercelNEXT_PUBLIC_API_URL, the Render/healthendpoint, and the RenderCORS_ORIGINSvalue.
Use this mode when you want to run PostgreSQL in Docker but run the backend and frontend directly on your computer. You need Python 3.11+, Node.js 20+, and PowerShell or a similar terminal.
Create or edit .env in the project root:
LLM_PROVIDER=groq
GROQ_API_KEY=your_groq_api_key
GROQ_MODEL=llama-3.1-8b-instant
DATABASE_URL=postgresql+psycopg://texttosql:texttosql@localhost:5432/texttosql
MAX_SQL_RETRIES=1
GOOGLE_CLIENT_ID=your_google_client_id
CORS_ORIGINS=http://localhost:3000For OpenRouter, use these settings instead:
LLM_PROVIDER=openrouter
OPENROUTER_API_KEY=your_openrouter_api_key
OPENROUTER_MODEL=meta-llama/llama-3.3-70b-instructFrom the project root, start only the database container:
docker compose up -d postgresConfirm that PostgreSQL is healthy before continuing:
docker compose psOpen a new terminal:
cd backend
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txtSeed the demo tables and create the local Chroma schema index:
python -m app.database.seed
python -m app.rag.ingestThe indexing command may take a few minutes the first time while the embedding model downloads.
Keep the backend terminal open and run:
uvicorn app.main:app --reload --port 8000Check that it is ready at http://localhost:8000/health before starting the frontend.
Open a second terminal:
cd frontend
npm install
copy .env.local.example .env.local
npm run devThe frontend uses http://localhost:8000 by default. To set it explicitly,
put this in frontend/.env.local:
NEXT_PUBLIC_API_URL=http://localhost:8000Open http://localhost:3000 when the frontend reports that it is ready.
You should have three running processes:
- PostgreSQL in Docker on port
5432. - FastAPI on port
8000. - Next.js on port
3000.
Stop the backend and frontend with Ctrl+C. Stop PostgreSQL from the project
root with:
docker compose stop postgresHow many customers are there?
What is the total revenue?
Show the top 5 customers by revenue.
Show revenue by country.
Which products generated the most sales?
Show customer names and their orders.
What was the revenue in January 2025?
Show customers from India who spent more than 50000.
What is the employee salary?
The final question is intentionally unsupported if the schema does not contain a salary column; the agent should explain that the requested information is unavailable.
The frontend provides a Dataset panel. You can upload several datasets at once, even when they use different formats, or select several previously uploaded datasets together. Supported formats are:
.csv, .tsv, .xlsx, .xls, .json, .jsonl, .ndjson, .parquet, .xml, .yaml, .yml
The authenticated API stores uploaded files in the repository-level datasets/
directory and derives a safe filename from the original upload name. Excel
files use openpyxl/xlrd, Parquet uses pyarrow, and YAML uses PyYAML.
To upload a dataset locally:
- Open http://localhost:3000 after the backend is ready.
- Click Upload datasets and choose one or more supported files.
- Wait for the upload and Indexing... status to finish.
- Ask questions about the active datasets. Separate uploaded files become separate SQL tables, so questions can join them when relationships are available or can be inferred safely.
Files must be readable by Pandas. If the page shows Failed to fetch, check that
the backend is running at http://localhost:8000/health, wait for startup
indexing to finish, and refresh the frontend.
Selecting a dataset automatically:
- Reads each file with the format-appropriate Pandas reader.
- Loads each file into the SQL database as its own
dataset_*table. - Records all selected files and generated table names.
- Rebuilds the Schema RAG index in a background task.
- Makes the datasets available after
/api/datasets/index-statusreports{"status":"ready"}.
Column names become SQL columns, so use simple, unique names when possible. Selecting a dataset replaces the generated table for that file name; it does not delete the demo tables or other generated tables.
Dataset intelligence is generated during upload and activation. The API
reports row counts, inferred column types, missing-value counts, unique-value
counts, duplicate rows, numeric ranges, date-like columns, categorical-column
signals, and a data-quality score. Excel workbooks are read sheet by sheet;
each sheet becomes a separate dataset_* table. When multiple datasets are
activated, the backend compares column names, data types, uniqueness, and
value overlap to infer likely relationships. These relationships are persisted
and included in the Schema RAG index so the agent can ground cross-dataset
joins.
The included dataset contains 369 rows and these columns:
date, product, category, price, quantity, revenue
Example questions:
What is the total revenue?
Show the top 5 products by revenue.
What is the revenue by category?
What is the average product price?
Except for /health, /api/auth/google, and /api/evaluate, API routes
require:
Authorization: Bearer <Google ID token>POST /api/auth/google accepts a Google credential and returns the verified
user profile.
{
"credential": "<google-id-token>"
}POST /api/query accepts a question between 2 and 2,000 characters:
{"question":"Show the top 5 customers by revenue."}The response contains sql, retrieved_tables, data, answer,
execution_time, retry_count, error, and metadata. data has this
shape:
{
"columns": ["customer_name", "revenue"],
"rows": [["Rahul", 2900]],
"row_count": 1,
"truncated": false,
"execution_time": 0.0123
}POST /api/query/stream accepts the same request and returns
text/event-stream. Each event is either a progress event with step and
message, a result event containing the complete query response, or an
error event containing message.
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/health |
Check backend readiness |
POST |
/api/query |
Run a natural-language question |
POST |
/api/sql/execute |
Validate and execute edited read-only SQL |
GET |
/api/schema |
Read the current database schema |
GET |
/api/datasets |
List available CSV datasets |
POST |
/api/datasets/upload |
Upload one or more supported dataset files |
POST |
/api/datasets/select |
Activate one or more datasets |
GET |
/api/datasets/index-status |
Check schema indexing status |
GET |
/api/datasets/selected |
Read the active dataset and generated table |
POST |
/api/auth/google |
Verify a Google credential |
POST |
/api/query/stream |
Run a query with SSE progress events |
POST |
/api/evaluate |
Return instructions for the offline evaluator |
Interactive API documentation is available at http://localhost:8000/docs when the backend is running.
To execute edited SQL, send a JSON body such as:
{"sql":"SELECT product, SUM(revenue) AS total_revenue FROM dataset_sales_data GROUP BY product ORDER BY total_revenue DESC LIMIT 5"}The response includes the original SQL, the result object, and the validator message. Invalid SQL returns HTTP 400.
For multiple uploads, send multipart form data using repeated files fields:
files=customers.csv
files=orders.parquet
files=products.xlsx
To activate multiple uploaded datasets, send their names in one request:
{"names":["customers.csv","orders.parquet","products.xlsx"]}The response includes selected_datasets, with the generated SQL table name,
row count, and columns for every activated file.
The agent first asks the LLM for a structured plan containing the intent, entity, metric, aggregation, filters, time range, grouping, ordering, limit, required tables, required columns, and join requirements. The retrieved Chroma schema is then checked against that plan before SQL generation. A request such as "What is the employee salary?" is rejected when no salary column is available instead of allowing the SQL generator to invent one.
After execution, a separate verifier compares the question, plan, SQL, and
result. This catches queries that run successfully but use the wrong
aggregation, filter, grouping, join, ordering, or limit. When verification
fails, the agent supplies the diagnosis to SQL repair and retries only within
MAX_CORRECTION_ATTEMPTS. If the budget is exhausted, the API returns the
diagnostic rather than presenting the unverified result as a confirmed answer.
The plan and verifier payloads are available in the response metadata.
From the project root:
python evaluation/runner.pyThe evaluator:
- Loads the dataset.
- Runs every question through the LangGraph agent.
- Records retrieved schema.
- Records generated SQL.
- Checks syntax/schema safety.
- Executes generated and reference SQL.
- Compares results.
- Runs DeepEval RAG/answer metrics when configured.
- Writes
evaluation/reports/latest.jsonandlatest.md. - Applies configurable quality thresholds.
cd backend
pytest -qThe tests include deterministic SQL-security checks and graph/API-level checks that do not require an LLM key.
Set:
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=...
LANGSMITH_PROJECT=text-to-sql-agentLangChain/LangGraph runs will then be available in LangSmith. The application also records useful run metadata such as provider, model, retrieved tables, SQL, retries and execution time.
Run the backend tests with:
cd backend
pytest -qThe tests cover CORS configuration and deterministic SQL validation. The full evaluation runner additionally exercises generated SQL against the evaluation dataset and may require a working LLM provider.
The application does not trust the LLM for SQL security. It applies deterministic validation before execution.
Allowed:
SELECTWITH
Rejected:
- INSERT
- UPDATE
- DELETE
- DROP
- ALTER
- TRUNCATE
- CREATE
- GRANT
- REVOKE
- multiple statements
- SQL comments
- unknown tables/columns
- unrestricted result sets
The demo executor also applies MAX_RESULT_ROWS.
For production PostgreSQL, use a dedicated read-only database role in addition to application-level validation.
The validator is a defense-in-depth control, not a replacement for database
permissions. Use a separate read-only PostgreSQL role, restrict network access
to the database, keep provider credentials outside Git, and configure exact
production CORS origins. Query results are capped by MAX_RESULT_ROWS, but
large intermediate database operations can still consume database resources.
Check that the backend is running, open /health, and confirm that
NEXT_PUBLIC_API_URL points to the backend. For deployed environments, also
confirm that the exact frontend origin is in CORS_ORIGINS and that the
backend has been restarted after changing it.
Confirm that the same client ID is configured in the frontend and backend, the
frontend origin is registered in Google Cloud, and the browser is loading the
Google Identity Services script. HTTP 503 means GOOGLE_CLIENT_ID is missing;
HTTP 401 means the credential could not be verified.
Wait for Ingested ... schema documents. and check
/api/datasets/index-status after dataset selection. Also verify the LLM API
key, DATABASE_URL, and PostgreSQL health. A running Uvicorn process does not
guarantee that the initial schema index is complete.
Set EMBEDDING_PROVIDER=hash for a lightweight deterministic embedding
provider, or allocate enough memory for the default Hugging Face model. Re-run
python -m app.rag.ingest after changing the provider so the Chroma collection
is rebuilt.
Uploaded CSVs and the local Chroma directory use the service filesystem. Render free instances may lose local files during redeploys or restarts. Use a persistent disk or external object storage for production datasets, and use a persistent database for the SQL data.
text-to-sql-agent/
├── backend/
│ ├── app/
│ │ ├── main.py
│ │ ├── config.py
│ │ ├── agent/
│ │ ├── rag/
│ │ ├── llm/
│ │ ├── database/
│ │ ├── security/
│ │ └── api/
│ ├── tests/
│ └── requirements.txt
├── evaluation/
│ ├── datasets/
│ ├── metrics.py
│ ├── runner.py
│ └── reports/
├── frontend/
├── database/
├── chroma/
├── docker-compose.yml
├── render.yaml
└── README.md
LangSmith is for observability/tracing; DeepEval is for offline/CI evaluation. They are intentionally kept separate in this project.