A small multi-agent AI data assistant — ask a question in plain English, get a safe SQL answer; drop in a messy file, get it cleaned and loaded.
Built with Python, PostgreSQL, and LangChain/LangGraph. No frontend, no bloat — a single FastAPI endpoint backed by three cooperating agents.
┌───────────────┐
"How many orders │ Orchestrator │
did we get in │ (router) │
March?" ───► └───────┬───────┘
│
┌────────────┴────────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ SQL Expert │ │ ETL Expert │
│ (LangGraph) │ │ (ReAct agent) │
│ │ │ │
│ NL -> safe │ │ extract -> │
│ read-only SQL │ │ clean -> │
│ -> answer │ │ load │
└───────┬───────┘ └───────┬───────┘
│ │
▼ ▼
┌─────────────────────────────────┐
│ PostgreSQL │
└─────────────────────────────────┘
Managed "chat with your data" products are expensive and opaque. QueryCove is a compact, from-scratch reference implementation of the same idea — routing, safe SQL generation, and ETL — that you can actually read end to end.
- Orchestrator — a lightweight LLM router that reads the request and decides whether it's a question about existing data or an instruction to load new data.
- SQL Expert agent — turns a plain-English question into read-only SQL, executes it, and answers in natural language.
- ETL Expert agent — a ReAct agent that extracts data from a file, cleans it (column mapping, date parsing, deduping), and loads it into Postgres.
This is the part most "AI + database" demos skip, so it's worth calling out:
- Belt and suspenders on SQL. The SQL agent connects through a dedicated
read-only Postgres role (
sql_agent_ro) that is physically incapable ofINSERT/UPDATE/DELETE/DROP— enforced by Postgres itself, not just a prompt. Generated SQL is also independently checked (single-statement,SELECT-only, keyword-denylist) before it's ever executed. - Explicit refusal, not silent rewriting. If a request asks the SQL agent to change
data, it refuses outright via a structured
refusedflag — it never quietly substitutes aSELECTfor what you actually asked. - Parameterized ETL, not arbitrary code. The ETL agent's
transform_datatool only ever does column renaming, date parsing, email normalization, and deduplication — the model picks the parameters, never arbitrary transform logic.
Requires uv and Docker.
# 1. Install dependencies
uv sync
# 2. Configure environment
cp .env.example .env
# then fill in a real OPENAI_API_KEY
# 3. Start Postgres (auto-seeds sample e-commerce data + the read-only role)
docker compose up -d db
# 4. Run the API
uv run uvicorn app.main:app --reloadTo reset and re-seed from scratch: docker compose down -v && docker compose up -d db
(the init scripts only run against an empty data volume).
# Health check (also verifies DB connectivity)
curl -s localhost:8000/health
# Ask a question -> routed to the SQL agent
curl -s -X POST localhost:8000/query -H 'Content-Type: application/json' \
-d '{"request": "Which 3 customers have spent the most money in total?"}' | jq
# Ask to load new data -> routed to the ETL agent
curl -s -X POST localhost:8000/query -H 'Content-Type: application/json' \
-d '{"request": "Load the new customers file into the database"}' | jqThere's no chat/file-upload UI on purpose — drop a file into data/incoming/ (e.g. the
included new_customers.csv, which is deliberately messy: mixed-case emails, US-style
dates, a duplicate, a missing field) before sending the request. The ETL agent looks
in that folder itself; the request text is just an instruction, not a file upload.
app/
├── orchestrator/ # LLM router: decides "sql" vs "etl"
├── agents/
│ ├── sql_agent/ # NL -> SQL -> answer, with safety.py as the SELECT-only gate
│ └── etl_agent/ # ReAct loop: extract, transform_data, load
├── api/ # FastAPI routes + schemas (POST /query, GET /health)
└── db.py # SQLAlchemy engines (read-write + read-only)
db/
├── init/ # Schema + role creation SQL, run on first container start
└── seed/ # Sample e-commerce CSVs (customers, products, orders, order_items)
uv run pytesttest_sql_safety.py,test_seed_data.py— pure unit tests, no external dependencies.test_sql_agent.py,test_etl_agent.py,test_orchestrator_routing.py— integration tests against a real OpenAI API and a running Postgres; they auto-skip unless bothOPENAI_API_KEYis a real key anddocker compose up -d dbis running.
- Migrations: plain SQL init scripts (
db/init/*.sql), not Alembic — there's no existing schema to migrate and no production deployment target for this project. Revisit with Alembic if the schema starts evolving after initial development. - SQL safety: enforced twice — once at the database role level, once at the
application level (
app/agents/sql_agent/safety.py) — so a bug in either layer alone can't lead to a write. - ETL transforms are parameterized, not arbitrary code, trading some flexibility for predictability and safety.
Python 3.12 · FastAPI · LangChain / LangGraph · PostgreSQL · Docker · uv