Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added Chinook.db
Binary file not shown.
49 changes: 36 additions & 13 deletions Dockerfile.txt
Original file line number Diff line number Diff line change
@@ -1,19 +1,42 @@
FROM python:3.11-slim
FROM alpine:3.22 AS importer
RUN apk add --no-cache pgloader
COPY <<EOF /entrypoint.sh
#!/bin/sh
set -xe
pgloader "\${SQLITE_FILE}" "\${DATABASE_URL}"
EOF
RUN chmod +x /entrypoint.sh
ENTRYPOINT [ "/entrypoint.sh" ]

# Environment
ENV PYTHONDONTWRITEBYTECODE=1
FROM python:3.13-slim AS agent
ENV PYTHONUNBUFFERED=1
RUN pip install uv

WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy \
uv pip install --system .
COPY agent.py .
RUN python -m compileall -q .
COPY <<EOF /entrypoint.sh
#!/bin/sh
set -e

# Install deps
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
if test -f /run/secrets/openai-api-key; then
export OPENAI_API_KEY=$(cat /run/secrets/openai-api-key)
fi

# Copy app
COPY . .

EXPOSE 8080

# Use Uvicorn for ASGI
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"]
if test -n "\${OPENAI_API_KEY}"; then
echo "Using OpenAI with \${OPENAI_MODEL_NAME}"
export MODEL_NAME=\${OPENAI_MODEL_NAME}
else
echo "Using Docker Model Runner with \${MODEL_RUNNER_MODEL}"
export OPENAI_BASE_URL=\${MODEL_RUNNER_URL}
export MODEL_NAME=\${MODEL_RUNNER_MODEL}
export OPENAI_API_KEY=cannot_be_empty
fi
exec python agent.py
EOF
RUN chmod +x /entrypoint.sh
ENTRYPOINT [ "/entrypoint.sh" ]
21 changes: 21 additions & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2024 LangChain, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
213 changes: 129 additions & 84 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,84 +1,129 @@
Here’s an updated project structure that reflects everything we’ve built so far: a hybrid FastAPI backend (robust logging, DB, health checks) + desktop GUI serving (static files, templates) + Electron wrapper for distribution. This structure is designed for scaling, testing, and packaging into installers.

---

📂 Updated Project Structure

`
project-root/
├── app/
│ ├── core/ # Core utilities
│ │ ├── logging.py
│ │ ├── security.py
│ │ └── config.py
│ │
│ ├── db/ # Database lifecycle
│ │ ├── session.py
│ │ └── models.py
│ │
│ ├── routes/ # API routers
│ │ ├── items_router.py
│ │ ├── users_router.py
│ │ └── auth_router.py
│ │
│ ├── api/ # Feature APIs
│ │ ├── search.py
│ │ └── stats.py
│ │
│ ├── static/ # GUI assets
│ │ ├── index.html
│ │ ├── style.css
│ │ └── app.js
│ │
│ ├── templates/ # Jinja2 templates
│ │ └── base.html
│ │
│ ├── main.py # Hybrid FastAPI app (API + GUI)
│ └── lifespan.py # DB init/shutdown hooks
├── electron-app/ # Electron wrapper
│ ├── main.js # Electron entry point
│ ├── preload.js # Optional preload scripts
│ ├── package.json # Electron dependencies
│ └── renderer/ # Extra frontend assets (optional)
├── tests/ # Unit + integration tests
│ ├── test_items.py
│ ├── test_users.py
│ ├── test_search.py
│ └── test_gui.py
├── run_desktop.py # Local launcher (FastAPI via uvicorn)
├── fastapi_app.spec # PyInstaller spec file
├── electron-builder.yml # Electron build config
├── requirements.txt # Python dependencies
├── package.json # Root Node/Electron config (optional)
├── Dockerfile # Containerization (optional)
└── README.md # Documentation
`

---

🔑 Best Practice Highlights

- Hybrid main.py → Combines API routers, GUI serving, middleware, health checks.
- rundesktop.py → Simple launcher for local desktop mode.
- Electron wrapper → main.js spawns FastAPI backend and opens native window.
- PyInstaller spec → Bundles backend into .exe or .app.
- Electron-builder config → Generates installers for Windows, macOS, Linux.
- Tests → Separate unit tests for API + GUI routes.

---

🚀 Scaling Path

1. Local dev → python run_desktop.py + npm start (Electron).
2. Packaging → pyinstaller fastapi_app.spec for backend, electron-builder for installers.
3. Distribution → Ship .exe, .dmg, .AppImage with auto‑updates.
4. Scaling DB → SQLite for local, Postgres for multi‑user.
5. Workers → Uvicorn --workers 4 for concurrency.

---

Would you like me to now draft the electron-builder.yml config so you can generate installers (.exe, .dmg, .AppImage) with one command?
# 🧠 SQL Agent with LangGraph

This project demonstrates a **zero-config AI agent** that uses [LangGraph] to answer natural language
questions by querying a SQL database — all orchestrated with [Docker Compose].

> [!Tip]
> ✨ No configuration needed — run it with a single command.

<p align="center">
<img src="demo.gif"
alt="Demo"
width="50%"
style="border: 1px solid #ccc; border-radius: 8px;" />
</p>

# 🚀 Getting Started

### Requirements

+ **[Docker Desktop] 4.43.0+ or [Docker Engine]** installed.
+ **A laptop or workstation with a GPU** (e.g., a MacBook) for running open models locally. If you
don't have a GPU, you can alternatively use **[Docker Offload]**.
+ If you're using [Docker Engine] on Linux or [Docker Desktop] on Windows, ensure that the
[Docker Model Runner requirements] are met (specifically that GPU
support is enabled) and the necessary drivers are installed.
+ If you're using Docker Engine on Linux, ensure you have [Docker Compose] 2.38.1 or later installed.

### Run the project

```sh
docker compose up
```

That’s all. The agent spins up automatically, sets up PostgreSQL, loads a pre-seeded database
(`Chinook.db`), and starts answering your questions.

# 🧠 Inference Options

By default, this project uses [Docker Model Runner] to handle LLM inference locally — no internet
connection or external API key is required.

If you’d prefer to use OpenAI instead:

1. Create a `secret.openai-api-key` file with your OpenAI API key:

```plaintext
sk-...
```

2. Restart the project with the OpenAI configuration:

```sh
docker compose down -v
docker compose -f compose.yaml -f compose.openai.yaml up
```

# ❓ What Can It Do?

The project lets you explore the [Chinkook database](https://github.com/lerocha/chinook-database) using
natural language. This database represents a digital media store with information regarding artists,
albums, media tracks, invoices, and customers.

The agent will write the SQL for your natural language questions:

+ “Who was the best-selling sales agent in 2010?”
+ “List the top 3 albums by sales.”
+ “How many customers are from Brazil?”

You can **customize the initial question** asked by the agent — just edit the question in `compose.yaml`.

Need to work with a **different dataset?** Simply swap out `Chinook.db` with your own SQLite file and
update the mount path in `compose.yaml`.

# 🧱 Project Structure

| File/Folder | Purpose |
| -------------- | ------------------------------------------------------------------------- |
| `compose.yaml` | Defines service orchestration and database import (from SQLite). |
| `Dockerfile` | Builds the container environment. |
| `agent.py` | Contains the LangGraph agent and logic for forming and answering queries. |
| `Chinook.db` | Example SQLite database — can be replaced with your own. |

# 🔧 Architecture Overview

```mermaid

flowchart TD
A[User] -->|Natural language query| B[Agent]

B --> C{Agent Flow}

C -->|Tool call| D[(Docker MCP Server)]
D -->|SQL Query| E[(PostgreSQL)]

C -->|LLM call| F[(Docker Model Runner)]

E -->|Results| D
D -->|Structured Output| B
F -->|Generated Answer| B

B -->|Response| A
```

+ The LangGraph-based agent transforms questions into SQL.
+ PostgreSQL is populated from a SQLite dump at runtime.
+ All components are fully containerized for plug-and-play usage.

# 🧹 Cleanup

To stop and remove containers and volumes:

```sh
docker compose down -v
```

# 📎 Credits

+ [LangGraph]
+ [PostgreSQL]
+ [Docker Compose]

[LangGraph]: https://github.com/langchain-ai/langgraph
[PostgreSQL]: https://postgresql.org
[Docker Compose]: https://github.com/docker/compose
[Docker Desktop]: https://www.docker.com/products/docker-desktop/
[Docker Engine]: https://docs.docker.com/engine/
[Docker Model Runner]: https://docs.docker.com/ai/model-runner/
[Docker Model Runner requirements]: https://docs.docker.com/ai/model-runner/
[Docker Offload]: https://www.docker.com/products/docker-offload/
85 changes: 85 additions & 0 deletions agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import asyncio
import os

from langchain.chat_models import init_chat_model
from langchain_mcp_adapters.tools import load_mcp_tools
from langgraph.prebuilt import create_react_agent
from mcp import ClientSession
from mcp.client.sse import sse_client

base_url = os.getenv("OPENAI_API_BASE_URL")
model = os.getenv("MODEL_NAME")
mcp_server_url = os.getenv("MCP_SERVER_URL")
api_key = os.getenv("OPENAI_API_KEY", "does_not_matter")

system_prompt = """
You are an agent designed to interact with a SQL database.
Given an input question, create a syntactically correct {dialect} query to run,
then look at the results of the query and return the answer. Unless the user
specifies a specific number of examples they wish to obtain, always limit your
query to at most {top_k} results.

You can order the results by a relevant column to return the most interesting
examples in the database. Never query for all the columns from a specific table,
only ask for the relevant columns given the question.

You MUST double check your query before executing it. If you get an error while
executing a query, rewrite the query and try again.

DO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the
database.

To start you should ALWAYS look at the tables in the database to see what you
can query. Do NOT skip this step.

Then you should query the schema of the most relevant tables.

For example, for PostgreSQL, you can use the following query to get the tables:

SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';

And to retrieve all columns of a specific table, you can use:
SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'your_table_name';

""".format(
dialect=os.getenv("DATABASE_DIALECT"),
top_k=5,
)


async def main():
if mcp_server_url is None:
raise ValueError("Please set the MCP_SERVER_URL environment variable.")

llm = init_chat_model(
model, model_provider="openai", api_key=api_key, base_url=base_url
)
async with sse_client(
url=mcp_server_url,
timeout=60,
) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()

tools = await load_mcp_tools(session)
print(f"MCP tools loaded: {tools}")
agent = create_react_agent(
llm,
tools=tools,
prompt=system_prompt,
)

question = os.getenv("QUESTION")
if not question:
raise ValueError(
"Please set the QUESTION environment variable with your question."
)

async for step in agent.astream(
{"messages": [{"role": "user", "content": question}]},
stream_mode="values",
):
step["messages"][-1].pretty_print()


asyncio.run(main())
10 changes: 10 additions & 0 deletions compose.openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
services:
agent:
environment:
- OPENAI_MODEL_NAME=gpt-4.1-mini
secrets:
- openai-api-key

secrets:
openai-api-key:
file: secret.openai-api-key
Loading
Loading