Spaces:
Sleeping
Sleeping
Fixed Langgraph Workflow
Browse files- .gitignore +2 -1
- app.py +109 -0
- pyproject.toml +5 -0
- src/embedding.py +18 -61
- src/graph.py +142 -218
- src/main.py +72 -57
- src/retrieval.py +0 -71
- src/tools.py +103 -0
- uv.lock +0 -0
.gitignore
CHANGED
|
@@ -5,4 +5,5 @@ __pycache__/
|
|
| 5 |
.env/
|
| 6 |
venv/.env
|
| 7 |
.env
|
| 8 |
-
|
|
|
|
|
|
| 5 |
.env/
|
| 6 |
venv/.env
|
| 7 |
.env
|
| 8 |
+
DockerFile
|
| 9 |
+
.dockerignore
|
app.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import requests
|
| 3 |
+
import uuid
|
| 4 |
+
|
| 5 |
+
st.set_page_config(page_title="Text2SQL Agent", layout="wide")
|
| 6 |
+
|
| 7 |
+
BACKEND_URL = "http://localhost:8000"
|
| 8 |
+
|
| 9 |
+
if "user_id" not in st.session_state:
|
| 10 |
+
st.session_state.user_id = str(uuid.uuid4())[:8]
|
| 11 |
+
|
| 12 |
+
if "thread_id" not in st.session_state:
|
| 13 |
+
st.session_state.thread_id = str(uuid.uuid4())
|
| 14 |
+
|
| 15 |
+
if "db_connected" not in st.session_state:
|
| 16 |
+
st.session_state.db_connected = False
|
| 17 |
+
|
| 18 |
+
if "connection_url" not in st.session_state:
|
| 19 |
+
st.session_state.connection_url = ""
|
| 20 |
+
|
| 21 |
+
if "messages" not in st.session_state:
|
| 22 |
+
st.session_state.messages = []
|
| 23 |
+
|
| 24 |
+
st.sidebar.markdown("---")
|
| 25 |
+
st.sidebar.subheader("Database")
|
| 26 |
+
|
| 27 |
+
connection_url_input = st.sidebar.text_input(
|
| 28 |
+
"Database connection URL",
|
| 29 |
+
value=st.session_state.connection_url,
|
| 30 |
+
type="password",
|
| 31 |
+
disabled=st.session_state.db_connected,
|
| 32 |
+
placeholder="postgresql://user:pass@host:5432/dbname",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
if not st.session_state.db_connected:
|
| 36 |
+
if st.sidebar.button("Connect database", use_container_width=True):
|
| 37 |
+
if not connection_url_input.strip():
|
| 38 |
+
st.sidebar.error("Enter a connection URL first.")
|
| 39 |
+
else:
|
| 40 |
+
with st.spinner("Ingesting schema..."):
|
| 41 |
+
try:
|
| 42 |
+
resp = requests.post(
|
| 43 |
+
f"{BACKEND_URL}/upload",
|
| 44 |
+
json={
|
| 45 |
+
"connection_url": connection_url_input,
|
| 46 |
+
"user_id": st.session_state.user_id,
|
| 47 |
+
},
|
| 48 |
+
timeout=60,
|
| 49 |
+
)
|
| 50 |
+
resp.raise_for_status()
|
| 51 |
+
st.session_state.connection_url = connection_url_input
|
| 52 |
+
st.session_state.db_connected = True
|
| 53 |
+
st.sidebar.success("Database connected.")
|
| 54 |
+
st.rerun()
|
| 55 |
+
except requests.exceptions.RequestException as e:
|
| 56 |
+
st.sidebar.error(f"Upload failed: {e}")
|
| 57 |
+
else:
|
| 58 |
+
st.sidebar.success("Database connected.")
|
| 59 |
+
if st.sidebar.button("Disconnect / change database", use_container_width=True):
|
| 60 |
+
st.session_state.db_connected = False
|
| 61 |
+
st.session_state.connection_url = ""
|
| 62 |
+
st.session_state.messages = []
|
| 63 |
+
st.session_state.thread_id = str(uuid.uuid4())
|
| 64 |
+
st.rerun()
|
| 65 |
+
|
| 66 |
+
st.sidebar.markdown("---")
|
| 67 |
+
if st.sidebar.button("Clear conversation", use_container_width=True):
|
| 68 |
+
st.session_state.messages = []
|
| 69 |
+
st.session_state.thread_id = str(uuid.uuid4())
|
| 70 |
+
st.rerun()
|
| 71 |
+
|
| 72 |
+
st.title("Text2SQL Agent")
|
| 73 |
+
|
| 74 |
+
if not st.session_state.db_connected:
|
| 75 |
+
st.info("Connect a database from the sidebar to start chatting.")
|
| 76 |
+
st.stop()
|
| 77 |
+
|
| 78 |
+
for msg in st.session_state.messages:
|
| 79 |
+
with st.chat_message(msg["role"]):
|
| 80 |
+
st.markdown(msg["content"])
|
| 81 |
+
|
| 82 |
+
user_input = st.chat_input("Ask a question about your data...")
|
| 83 |
+
|
| 84 |
+
if user_input:
|
| 85 |
+
st.session_state.messages.append({"role": "user", "content": user_input})
|
| 86 |
+
with st.chat_message("user"):
|
| 87 |
+
st.markdown(user_input)
|
| 88 |
+
|
| 89 |
+
with st.chat_message("assistant"):
|
| 90 |
+
with st.spinner("Thinking..."):
|
| 91 |
+
try:
|
| 92 |
+
resp = requests.post(
|
| 93 |
+
f"{BACKEND_URL}/chat",
|
| 94 |
+
json={
|
| 95 |
+
"connection_url": st.session_state.connection_url,
|
| 96 |
+
"message": user_input,
|
| 97 |
+
"user_id": st.session_state.user_id,
|
| 98 |
+
"thread_id": st.session_state.thread_id,
|
| 99 |
+
},
|
| 100 |
+
timeout=120,
|
| 101 |
+
)
|
| 102 |
+
resp.raise_for_status()
|
| 103 |
+
answer = resp.json().get("response", "No response received.")
|
| 104 |
+
except requests.exceptions.RequestException as e:
|
| 105 |
+
answer = f"Error reaching the agent: {e}"
|
| 106 |
+
|
| 107 |
+
st.markdown(answer)
|
| 108 |
+
|
| 109 |
+
st.session_state.messages.append({"role": "assistant", "content": answer})
|
pyproject.toml
CHANGED
|
@@ -26,4 +26,9 @@ dependencies = [
|
|
| 26 |
"oracledb>=4.0.1",
|
| 27 |
"snowflake-sqlalchemy>=1.10.0",
|
| 28 |
"sqlalchemy-bigquery>=1.17.0",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
]
|
|
|
|
| 26 |
"oracledb>=4.0.1",
|
| 27 |
"snowflake-sqlalchemy>=1.10.0",
|
| 28 |
"sqlalchemy-bigquery>=1.17.0",
|
| 29 |
+
"langchain-chroma>=1.1.0",
|
| 30 |
+
"langchain-huggingface>=1.2.2",
|
| 31 |
+
"sentence-transformers>=5.7.0",
|
| 32 |
+
"langchain-classic>=1.0.7",
|
| 33 |
+
"rank-bm25>=0.2.2",
|
| 34 |
]
|
src/embedding.py
CHANGED
|
@@ -1,73 +1,30 @@
|
|
| 1 |
-
from
|
| 2 |
-
from
|
| 3 |
-
from fastembed import TextEmbedding, SparseTextEmbedding
|
| 4 |
import uuid
|
| 5 |
-
from dotenv import load_dotenv
|
| 6 |
-
import os
|
| 7 |
from src.scheme import create_scheme
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
|
| 12 |
load_dotenv()
|
| 13 |
|
| 14 |
-
|
| 15 |
-
qdrant_url = os.getenv("QDRANT_URL")
|
| 16 |
-
hf_token = os.getenv("HF_TOKEN")
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
|
| 24 |
-
|
| 25 |
-
client.create_collection(collection_name=COLLECTION_NAME,
|
| 26 |
-
vectors_config={"dense": VectorParams(size=384, distance=Distance.COSINE)},
|
| 27 |
-
sparse_vectors_config={"sparse": SparseVectorParams()})
|
| 28 |
-
|
| 29 |
-
try:
|
| 30 |
-
client.create_payload_index(
|
| 31 |
-
collection_name=COLLECTION_NAME,
|
| 32 |
-
field_name="user_id",
|
| 33 |
-
field_schema="keyword",
|
| 34 |
-
)
|
| 35 |
-
except Exception:
|
| 36 |
-
pass
|
| 37 |
-
|
| 38 |
docs = create_scheme(connection_url)
|
| 39 |
-
text = [doc.page_content for doc in docs]
|
| 40 |
-
|
| 41 |
-
dense_vectors = list(dense_model.embed(text))
|
| 42 |
-
sparse_vectors = list(sparse_model.embed(text))
|
| 43 |
-
|
| 44 |
-
points = []
|
| 45 |
-
|
| 46 |
-
for i , doc in enumerate(docs) :
|
| 47 |
-
dense_vector = dense_vectors[i].tolist()
|
| 48 |
-
|
| 49 |
-
sparse_embeddings = sparse_vectors[i]
|
| 50 |
-
|
| 51 |
-
sparse_vector = {
|
| 52 |
-
'indices' : sparse_embeddings.indices.tolist(),
|
| 53 |
-
'values' : sparse_embeddings.values.tolist()
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
table_id = str(uuid.uuid4())
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
vector = {
|
| 61 |
-
"dense" : dense_vector ,
|
| 62 |
-
"sparse" : sparse_vector
|
| 63 |
-
},
|
| 64 |
-
payload = {
|
| 65 |
-
'user_id' : user_id,
|
| 66 |
-
'text' : doc.page_content,
|
| 67 |
-
'table_name' : doc.metadata.get("table_name")
|
| 68 |
-
}
|
| 69 |
-
)
|
| 70 |
|
| 71 |
-
|
| 72 |
|
| 73 |
-
|
|
|
|
| 1 |
+
from langchain_chroma import Chroma
|
| 2 |
+
from langchain_huggingface import HuggingFaceEmbeddings
|
|
|
|
| 3 |
import uuid
|
|
|
|
|
|
|
| 4 |
from src.scheme import create_scheme
|
| 5 |
+
import os
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
import chromadb
|
| 8 |
|
| 9 |
load_dotenv()
|
| 10 |
|
| 11 |
+
COLLECTION_NAME = "Text2SQL"
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
chroma_client = chromadb.CloudClient(
|
| 14 |
+
api_key=os.getenv("CHROMA_API_KEY"),
|
| 15 |
+
tenant=os.getenv("CHROMA_TENANT"),
|
| 16 |
+
database=os.getenv("CHROMA_DATABASE"),
|
| 17 |
+
)
|
| 18 |
|
| 19 |
+
embedding_model = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
|
| 20 |
+
vectorstore = Chroma(collection_name=COLLECTION_NAME,embedding_function=embedding_model,client=chroma_client)
|
| 21 |
|
| 22 |
+
def create_embeddings(connection_url : str , user_id : str) :
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
docs = create_scheme(connection_url)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
for doc in docs :
|
| 26 |
+
doc.metadata['user_id'] = user_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
+
ids = [str(uuid.uuid4()) for _ in docs]
|
| 29 |
|
| 30 |
+
vectorstore.add_documents(documents=docs , ids=ids)
|
src/graph.py
CHANGED
|
@@ -1,228 +1,152 @@
|
|
| 1 |
-
from typing import TypedDict , Annotated , List , Optional
|
| 2 |
-
from langgraph.graph.message import add_messages
|
| 3 |
-
from langchain_core.messages import SystemMessage , HumanMessage
|
| 4 |
-
from langchain_openai import ChatOpenAI
|
| 5 |
-
from src.retrieval import retrieve
|
| 6 |
import os
|
|
|
|
|
|
|
|
|
|
| 7 |
from dotenv import load_dotenv
|
| 8 |
-
from
|
| 9 |
-
from
|
| 10 |
-
import
|
| 11 |
-
from
|
| 12 |
|
| 13 |
load_dotenv()
|
| 14 |
|
| 15 |
-
class State(TypedDict) :
|
| 16 |
-
connection_url : str
|
| 17 |
-
user_id : str
|
| 18 |
-
messages : Annotated[List , add_messages]
|
| 19 |
-
scheme : str
|
| 20 |
-
sql_query : str
|
| 21 |
-
query_result : str
|
| 22 |
-
error : Optional[str]
|
| 23 |
-
retry : int
|
| 24 |
-
final_result : str
|
| 25 |
-
|
| 26 |
-
|
| 27 |
llm = ChatOpenAI(
|
| 28 |
-
model="openai/gpt-
|
| 29 |
-
openai_api_key=os.getenv("
|
| 30 |
-
openai_api_base="https://
|
| 31 |
-
temperature=0
|
| 32 |
)
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
user_id = state.get("user_id")
|
| 41 |
-
|
| 42 |
-
query = messages[-1].content
|
| 43 |
-
|
| 44 |
-
scheme = retrieve(user_id , query , db_url)
|
| 45 |
-
|
| 46 |
-
return {'scheme' : scheme}
|
| 47 |
-
|
| 48 |
-
def generate_node(state : State) :
|
| 49 |
-
messages = state.get("messages")
|
| 50 |
-
scheme = state.get("scheme")
|
| 51 |
-
error = state.get("error")
|
| 52 |
-
wrong_query = state.get('sql_query')
|
| 53 |
-
|
| 54 |
-
llm_with_structured_output = llm.with_structured_output(sql_query)
|
| 55 |
-
|
| 56 |
-
history_messages = messages[:-1]
|
| 57 |
-
current_query_string = messages[-1].content
|
| 58 |
-
|
| 59 |
-
current_date = datetime.datetime.now().strftime("%Y-%m-%d")
|
| 60 |
-
|
| 61 |
-
if history_messages:
|
| 62 |
-
history_text = "\n".join([
|
| 63 |
-
f"{msg.type.capitalize()}: {msg.content}"
|
| 64 |
-
for msg in history_messages
|
| 65 |
-
])
|
| 66 |
-
else:
|
| 67 |
-
history_text = "This is the first user request. No history exists."
|
| 68 |
-
|
| 69 |
-
if error and wrong_query :
|
| 70 |
-
error_context = f"""
|
| 71 |
-
=== 🚨 ERROR CORRECTION MODE 🚨 ===
|
| 72 |
-
Your previous attempt to answer this request failed.
|
| 73 |
-
[PREVIOUS BROKEN QUERY]:
|
| 74 |
-
{wrong_query}
|
| 75 |
-
|
| 76 |
-
[DATABASE ERROR MESSAGE]:
|
| 77 |
-
{error}
|
| 78 |
-
|
| 79 |
-
INSTRUCTION: Analyze the error message and the schema carefully. Fix the syntax, column names, or logic, and generate a CORRECTED query.
|
| 80 |
-
"""
|
| 81 |
-
else :
|
| 82 |
-
error_context = ""
|
| 83 |
-
|
| 84 |
-
system_prompt = SystemMessage(content=f"""
|
| 85 |
-
You are an expert Data Analyst and SQL Engineer.
|
| 86 |
-
|
| 87 |
-
Your task is to generate ONE valid SELECT query for the latest user request.
|
| 88 |
-
|
| 89 |
-
=== DATABASE SCHEMA & DIALECT ===
|
| 90 |
-
{scheme}
|
| 91 |
-
|
| 92 |
-
=== CONVERSATION HISTORY ===
|
| 93 |
-
{history_text}
|
| 94 |
-
|
| 95 |
-
=== ERROR CORRECTION MODE ===
|
| 96 |
-
{error_context}
|
| 97 |
-
|
| 98 |
-
=== CRITICAL RULES ===
|
| 99 |
-
1. Use ONLY tables and columns that exist in the schema.
|
| 100 |
-
2. Never hallucinate columns, joins, or tables.
|
| 101 |
-
3. Generate only SELECT queries. No INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER.
|
| 102 |
-
4. Use the exact SQL dialect implied by the schema metadata.
|
| 103 |
-
5. For any output columns, ALWAYS use clear aliases.
|
| 104 |
-
Example:
|
| 105 |
-
- customer_id AS customer_id
|
| 106 |
-
- customer_name AS customer_name
|
| 107 |
-
- SUM(amount) AS total_amount
|
| 108 |
-
6. When the user asks for a person/customer/company/product/entity, return BOTH:
|
| 109 |
-
- the readable name field if it exists
|
| 110 |
-
- the matching ID field
|
| 111 |
-
7. If a name exists in another table, join to fetch it.
|
| 112 |
-
8. If no readable name exists, return the best human-readable identifier available, and the ID.
|
| 113 |
-
9. For aggregate queries, include a label column when possible so the answer layer can explain the result.
|
| 114 |
-
10. If fixing an error, preserve the original user intent and correct only the broken parts.
|
| 115 |
-
|
| 116 |
-
=== PRIORITY RULE FOR ID VS NAME ===
|
| 117 |
-
- Priority 1: name + id together, if possible
|
| 118 |
-
- Priority 2: name only, if name exists but id cannot be included
|
| 119 |
-
- Priority 3: id only, only if no readable name exists
|
| 120 |
-
|
| 121 |
-
=== OUTPUT FORMAT REQUIREMENT ===
|
| 122 |
-
Return a SQL query whose selected columns are self-explanatory.
|
| 123 |
-
Do not rely on positional meaning like column 1, column 2 without aliases.
|
| 124 |
-
|
| 125 |
-
=== CURRENT DATE ===
|
| 126 |
-
Today's date is {current_date}.
|
| 127 |
-
""")
|
| 128 |
-
|
| 129 |
-
final_msg = [
|
| 130 |
-
system_prompt,
|
| 131 |
-
HumanMessage(content=f"LATEST USER REQUEST:\n{current_query_string}")
|
| 132 |
-
]
|
| 133 |
-
|
| 134 |
-
response = llm_with_structured_output.invoke(final_msg)
|
| 135 |
-
|
| 136 |
-
return {'sql_query' : response.generated_sql_query , "error" : None}
|
| 137 |
-
|
| 138 |
-
def execute_node(state : State) :
|
| 139 |
-
url = state.get("connection_url")
|
| 140 |
-
sql_query = state.get("sql_query")
|
| 141 |
-
retry = state.get("retry" , 0)
|
| 142 |
-
|
| 143 |
-
try :
|
| 144 |
-
db = SQLDatabase.from_uri(url)
|
| 145 |
-
|
| 146 |
-
result = db.run(sql_query)
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
workflow.add_node("execute_node" , execute_node)
|
| 220 |
-
workflow.add_node("answer_node" , answer_node)
|
| 221 |
-
|
| 222 |
-
workflow.add_edge(START , "retrieve_node")
|
| 223 |
-
workflow.add_edge("retrieve_node" , "generate_node")
|
| 224 |
-
workflow.add_edge("generate_node" , "execute_node")
|
| 225 |
-
workflow.add_conditional_edges("execute_node" , routing , {
|
| 226 |
-
"answer_node" : "answer_node" , "generate_node" : "generate_node"
|
| 227 |
-
})
|
| 228 |
-
workflow.add_edge("answer_node" , END)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from langchain.agents.middleware import dynamic_prompt, ModelRequest
|
| 5 |
from dotenv import load_dotenv
|
| 6 |
+
from langchain_openai import ChatOpenAI
|
| 7 |
+
from langchain.agents import create_agent
|
| 8 |
+
from langchain.agents.middleware import SummarizationMiddleware,PIIMiddleware,ToolCallLimitMiddleware
|
| 9 |
+
from src.tools import retrieve, execute_query
|
| 10 |
|
| 11 |
load_dotenv()
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
llm = ChatOpenAI(
|
| 14 |
+
model="openai/gpt-oss-120b",
|
| 15 |
+
openai_api_key=os.getenv("GROQ_API_KEY"),
|
| 16 |
+
openai_api_base="https://api.groq.com/openai/v1",
|
| 17 |
+
temperature=0,
|
| 18 |
)
|
| 19 |
|
| 20 |
+
summarizer_llm = ChatOpenAI(
|
| 21 |
+
model="llama-3.1-8b-instant",
|
| 22 |
+
openai_api_key=os.getenv("GROQ_API_KEY"),
|
| 23 |
+
openai_api_base="https://api.groq.com/openai/v1",
|
| 24 |
+
temperature=0,
|
| 25 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
@dataclass
|
| 28 |
+
class AgentContext:
|
| 29 |
+
user_id: str
|
| 30 |
+
connection_url : str
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
SYSTEM_PROMPT =f"""<role>
|
| 34 |
+
You are a enterprise Text to SQL agent that strictly answer the given user query from the user provided database.
|
| 35 |
+
</role>
|
| 36 |
+
|
| 37 |
+
<Working Rule>
|
| 38 |
+
Before answering any given user query, you must strictly decide : RETRIEVE or REUSE.
|
| 39 |
+
This decision must be made explicitly without any assumption.
|
| 40 |
+
If the current user history contains sufficient information to answer the user query completely without any knowledge gap and you are certain about it, You should REUSE.
|
| 41 |
+
Otherwise : RETRIEVE
|
| 42 |
+
|
| 43 |
+
<example type="correct_reuse">
|
| 44 |
+
Turn 1 : User : "Give me all the users and their expenses"
|
| 45 |
+
-> retrieve(query) -> relevant tables and columns to generate the SQL query.
|
| 46 |
+
-> execute_query(SQL Query) -> get the results with all users and their expenses.
|
| 47 |
+
-> Answer using the results.
|
| 48 |
+
|
| 49 |
+
Turn 2 : User : "Give me the user with the highest expenses."
|
| 50 |
+
-> The previous messages in conversation history contains all the users and their expenses, sufficient enough to answer the user query without any retrieval.
|
| 51 |
+
Decision : REUSE
|
| 52 |
+
- Answer without any tool call
|
| 53 |
+
</example>
|
| 54 |
+
|
| 55 |
+
<example type="incorrect_reuse">
|
| 56 |
+
Turn 1 - User: "Give me all the products listed for more than 100"
|
| 57 |
+
-> retrieve(query) -> relevant tables and columns retrieved.
|
| 58 |
+
-> execute_query(SQL Query) -> get the results with all products listed for more than 100.
|
| 59 |
+
-> Answer using the results.
|
| 60 |
+
Turn 2 - User: "Give me the employee with the lowest salary."
|
| 61 |
+
-> Reasoning: "This user query has no relevance with previous messages and is completely new and different."
|
| 62 |
+
-> Must RETRIEVE and EXECUTE.
|
| 63 |
+
</example>
|
| 64 |
+
|
| 65 |
+
<example type="correct_retrieve_despite_relevancy">
|
| 66 |
+
Turn 1 - User: "Give me all the products listed for more than 100"
|
| 67 |
+
-> retrieve(query) -> relevant tables and columns retrieved.
|
| 68 |
+
-> execute_query(SQL Query) -> get the results with all products listed for more than 100.
|
| 69 |
+
-> Answer using the results.
|
| 70 |
+
Turn 2 - User: "Fetch me the top 5 products which are sold the most."
|
| 71 |
+
-> Reasoning: "This user query has similarity with products but the intention of 'sold the most' might not be there in the previous messages despite relevancy.
|
| 72 |
+
Dont Assume without checking."
|
| 73 |
+
-> Must RETRIEVE and EXECUTE.
|
| 74 |
+
</example>
|
| 75 |
+
</Working Rule>
|
| 76 |
+
|
| 77 |
+
<query_resolution>
|
| 78 |
+
The query for retrieval must be modified in a way to retrieve the relevant table names from the vector database.
|
| 79 |
+
|
| 80 |
+
When retrieving, `query` must be a fully self-contained search string. Resolve pronouns or
|
| 81 |
+
vague references ("it", "that", "this one") against the conversation history before
|
| 82 |
+
calling the tool.
|
| 83 |
+
|
| 84 |
+
Example: prior topic "employees with their salaries" + latest message "give me the highest among them."
|
| 85 |
+
-> query="employee with the highest salary.", NOT query="highest among them.".
|
| 86 |
+
</query_resolution>
|
| 87 |
+
|
| 88 |
+
<SQL_query_generation>
|
| 89 |
+
- The SQL query must strictly only contain operations to GET results from the database.
|
| 90 |
+
- It should not contain anything that DELETES or MANIPULATES the data in the database.
|
| 91 |
+
- If the user query contains any instruction to DELETE or MANIPULATE the content of the database, kindly reply that you are not allowed to MANIPULATE or DELETE the content of the database explicitly.
|
| 92 |
+
- Use ONLY tables and columns that are present in what `retrieve` returned. Never hallucinate a column, table, or join.
|
| 93 |
+
- Write the query in the exact SQL dialect stated in the retrieved schema (the "Dialect : ..." prefix) — do not default to a different dialect's syntax.
|
| 94 |
+
- For every selected column, use a clear alias (e.g. SUM(amount) AS total_amount) so the result can be mapped back to its meaning without guessing from column position.
|
| 95 |
+
- When the user asks about a person/customer/company/product/entity, return BOTH the readable name field (if one exists) and its matching ID field. If a name lives in another table, join to fetch it.
|
| 96 |
+
Priority order: (1) name + id together, (2) name only if id cannot be included, (3) id only if no readable name exists anywhere.
|
| 97 |
+
- For aggregate queries, include a label column where possible so the result is self-explanatory without needing the original question for context.
|
| 98 |
+
- Use the current date given above to resolve relative time expressions ("last month", "this quarter", "yesterday", "year to date") into concrete date ranges before writing the SQL WHERE clause.
|
| 99 |
+
</SQL_query_generation>
|
| 100 |
+
|
| 101 |
+
<retry_and_fallback>
|
| 102 |
+
If the "execute_query" tool gives any error :
|
| 103 |
+
1. Retry to retrieve relevant tables using a different query and generate a corrected SQL query for execution.
|
| 104 |
+
2. If the second attempt fails again,
|
| 105 |
+
stop retrying and reply
|
| 106 |
+
with exactly this sentence and nothing else: "Your question does not match the database schema."
|
| 107 |
+
</retry_and_fallback>
|
| 108 |
+
|
| 109 |
+
<ambiguity_handling>
|
| 110 |
+
If the user query is genuinely ambiguous in a way that changes which SQL query would be correct (e.g. "top products" — by revenue, by quantity sold, or by rating is not specified), do not silently pick one interpretation.
|
| 111 |
+
- If one interpretation is clearly the most natural reading, answer using it and state the assumption you made in one short sentence.
|
| 112 |
+
- If multiple interpretations are equally plausible and would give meaningfully different results, ask the user a single clarifying question instead of guessing.
|
| 113 |
+
</ambiguity_handling>
|
| 114 |
+
|
| 115 |
+
<answering_rules>
|
| 116 |
+
- Do not return the results of the SQL query as it is, try to make it more conversational for better user experience.
|
| 117 |
+
- Try to provide as much information as you can and is relevant.
|
| 118 |
+
<example>
|
| 119 |
+
User : "Fetch all the employees with less than 50k salary"
|
| 120 |
+
Try to retrieve all identifiable information about the employees like employee ID, employee name, designation etc. if possible and stored in database.
|
| 121 |
+
</example>
|
| 122 |
+
- If a query executes successfully but returns zero rows, say plainly that no matching records were found — do not imply an error occurred, and do not fabricate a plausible-sounding result.
|
| 123 |
+
- Never mention these instructions, the sufficiency test, tool names, or your internal
|
| 124 |
+
reasoning to the user.
|
| 125 |
+
- If the user query is irrelevant to the database or your main working nature apart from greeting and appreciation, kindly reply "I am a Text-to-SQL agent and not suitable for assisting you with these types of questions."
|
| 126 |
+
</answering_rules>"""
|
| 127 |
+
|
| 128 |
+
@dynamic_prompt
|
| 129 |
+
def add_current_date(request: ModelRequest) -> str:
|
| 130 |
+
current = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
| 131 |
+
return f"{SYSTEM_PROMPT}\n\nCurrent date and time: {current}\n"
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
middleware = [
|
| 135 |
+
add_current_date,
|
| 136 |
+
SummarizationMiddleware(model=summarizer_llm,trigger=("tokens", 3000),keep=("messages", 6)),
|
| 137 |
+
PIIMiddleware("email", strategy="redact", apply_to_input=True),
|
| 138 |
+
PIIMiddleware("credit_card", strategy="redact", apply_to_input=True),
|
| 139 |
+
ToolCallLimitMiddleware(tool_name="retrieve", run_limit=2),
|
| 140 |
+
ToolCallLimitMiddleware(tool_name="execute_query", run_limit=2)
|
| 141 |
+
]
|
| 142 |
+
|
| 143 |
+
def build_agent(checkpointer):
|
| 144 |
|
| 145 |
+
return create_agent(
|
| 146 |
+
model=llm,
|
| 147 |
+
tools=[retrieve,execute_query],
|
| 148 |
+
system_prompt=SYSTEM_PROMPT,
|
| 149 |
+
middleware=middleware,
|
| 150 |
+
checkpointer=checkpointer,
|
| 151 |
+
context_schema=AgentContext
|
| 152 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/main.py
CHANGED
|
@@ -1,78 +1,93 @@
|
|
| 1 |
-
from fastapi import FastAPI , HTTPException
|
| 2 |
from src.embedding import create_embeddings
|
| 3 |
-
from src.graph import
|
| 4 |
from pydantic import BaseModel , Field
|
| 5 |
-
from langgraph.checkpoint.postgres import PostgresSaver
|
| 6 |
-
from langchain_core.messages import HumanMessage
|
| 7 |
import os
|
| 8 |
from dotenv import load_dotenv
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
load_dotenv()
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
app = FastAPI(
|
| 13 |
title="Text2SQL Agent API",
|
| 14 |
description="A production-grade backend powering LangGraph agent.",
|
| 15 |
-
version="1.0.0"
|
| 16 |
-
)
|
| 17 |
|
| 18 |
-
class
|
| 19 |
-
connection_url: str = Field(...
|
| 20 |
-
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
class
|
| 23 |
-
|
| 24 |
-
thread_id
|
| 25 |
-
|
| 26 |
-
connection_url : str = Field(...,description="Database URL")
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
return {
|
| 33 |
-
"status"
|
| 34 |
-
"message"
|
| 35 |
}
|
| 36 |
|
| 37 |
-
@app.post("/chat"
|
| 38 |
-
def chat_endpoint(request
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
print(f"DATABASE_URI = {repr(db_uri)}", flush=True)
|
| 42 |
|
| 43 |
-
|
| 44 |
-
checkpointer.setup()
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
| 48 |
)
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
initial_state = {
|
| 56 |
-
'connection_url' : request.connection_url ,
|
| 57 |
-
'user_id' : request.user_id ,
|
| 58 |
-
'messages' : [HumanMessage(content=request.message)],
|
| 59 |
-
'retry' : 0
|
| 60 |
-
}
|
| 61 |
-
try :
|
| 62 |
-
result = agent.invoke(initial_state , config=config)
|
| 63 |
-
|
| 64 |
-
final_result = result.get("final_result")
|
| 65 |
-
|
| 66 |
-
print("*"*50 , flush=True)
|
| 67 |
-
print(f"\n\n Scheme : {result['scheme']}\n\n" , flush=True)
|
| 68 |
-
print(f"\n\nSql Query : {result['sql_query']}\n\n" , flush=True)
|
| 69 |
-
print(f"\n\nQuery Result : {result['query_result']}\n\n" , flush=True)
|
| 70 |
-
|
| 71 |
-
return {
|
| 72 |
-
"status": "success",
|
| 73 |
-
"thread_id": request.thread_id,
|
| 74 |
-
"response": final_result
|
| 75 |
-
}
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI , HTTPException
|
| 2 |
from src.embedding import create_embeddings
|
| 3 |
+
from src.graph import build_agent, AgentContext
|
| 4 |
from pydantic import BaseModel , Field
|
|
|
|
|
|
|
| 5 |
import os
|
| 6 |
from dotenv import load_dotenv
|
| 7 |
+
import asyncio
|
| 8 |
+
from psycopg_pool import AsyncConnectionPool
|
| 9 |
+
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
| 10 |
+
from contextlib import asynccontextmanager
|
| 11 |
+
import logging
|
| 12 |
|
| 13 |
load_dotenv()
|
| 14 |
|
| 15 |
+
logging.basicConfig(level=logging.INFO)
|
| 16 |
+
logger = logging.getLogger("text2sql")
|
| 17 |
+
|
| 18 |
+
DB_URI = os.getenv("DATABASE_URI")
|
| 19 |
+
|
| 20 |
+
@asynccontextmanager
|
| 21 |
+
async def lifespan(app: FastAPI):
|
| 22 |
+
async with AsyncConnectionPool(
|
| 23 |
+
conninfo=DB_URI,
|
| 24 |
+
min_size=1,
|
| 25 |
+
max_size=15,
|
| 26 |
+
max_idle=300,
|
| 27 |
+
max_lifetime=1800,
|
| 28 |
+
reconnect_timeout=10,
|
| 29 |
+
kwargs={"autocommit": True, "prepare_threshold": 0},
|
| 30 |
+
check=AsyncConnectionPool.check_connection,
|
| 31 |
+
open=False,
|
| 32 |
+
) as pool:
|
| 33 |
+
await pool.open(wait=True, timeout=15)
|
| 34 |
+
checkpointer = AsyncPostgresSaver(pool)
|
| 35 |
+
await checkpointer.setup()
|
| 36 |
+
app.state.pool = pool
|
| 37 |
+
app.state.agent = build_agent(checkpointer)
|
| 38 |
+
yield
|
| 39 |
+
|
| 40 |
app = FastAPI(
|
| 41 |
title="Text2SQL Agent API",
|
| 42 |
description="A production-grade backend powering LangGraph agent.",
|
| 43 |
+
version="1.0.0",
|
| 44 |
+
lifespan=lifespan)
|
| 45 |
|
| 46 |
+
class ChatRequest(BaseModel):
|
| 47 |
+
connection_url : str = Field(...)
|
| 48 |
+
message: str = Field(...)
|
| 49 |
+
user_id: str = Field(...)
|
| 50 |
+
thread_id: str = Field(...)
|
| 51 |
|
| 52 |
+
class ChatResponse(BaseModel):
|
| 53 |
+
status: str
|
| 54 |
+
thread_id: str
|
| 55 |
+
response: str
|
|
|
|
| 56 |
|
| 57 |
+
class UploadRequest(BaseModel) :
|
| 58 |
+
connection_url : str = Field(...)
|
| 59 |
+
user_id : str = Field(...)
|
| 60 |
+
|
| 61 |
+
@app.post("/upload")
|
| 62 |
+
async def upload_url(request : UploadRequest):
|
| 63 |
+
await asyncio.to_thread(create_embeddings , request.connection_url , request.user_id)
|
| 64 |
|
| 65 |
return {
|
| 66 |
+
"status": "success",
|
| 67 |
+
"message": "You can now chat with the agent."
|
| 68 |
}
|
| 69 |
|
| 70 |
+
@app.post("/chat",response_model=ChatResponse)
|
| 71 |
+
async def chat_endpoint(request: ChatRequest):
|
| 72 |
+
agent = app.state.agent
|
|
|
|
|
|
|
| 73 |
|
| 74 |
+
config = {"configurable": {"thread_id": request.thread_id}}
|
|
|
|
| 75 |
|
| 76 |
+
try:
|
| 77 |
+
result = await agent.ainvoke(
|
| 78 |
+
{"messages": [{"role": "user", "content": request.message}]},
|
| 79 |
+
config=config,
|
| 80 |
+
context=AgentContext(user_id=request.user_id , connection_url=request.connection_url),
|
| 81 |
)
|
| 82 |
+
except Exception:
|
| 83 |
+
logger.exception("Agent processing failed")
|
| 84 |
+
raise HTTPException(status_code=500, detail="Agent Processing Error!")
|
| 85 |
+
output_messages = result.get("messages", [])
|
| 86 |
+
if not output_messages:
|
| 87 |
+
raise HTTPException(status_code=500, detail="No messages returned from the agent.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
+
return ChatResponse(
|
| 90 |
+
status="success",
|
| 91 |
+
thread_id=request.thread_id,
|
| 92 |
+
response=output_messages[-1].content,
|
| 93 |
+
)
|
src/retrieval.py
DELETED
|
@@ -1,71 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
from dotenv import load_dotenv
|
| 3 |
-
from qdrant_client import QdrantClient
|
| 4 |
-
from qdrant_client import models
|
| 5 |
-
from fastembed import TextEmbedding, SparseTextEmbedding
|
| 6 |
-
from langchain_community.utilities import SQLDatabase
|
| 7 |
-
from huggingface_hub import login
|
| 8 |
-
|
| 9 |
-
load_dotenv()
|
| 10 |
-
|
| 11 |
-
qdrant_api = os.getenv("QDRANT_API_KEY")
|
| 12 |
-
qdrant_url = os.getenv("QDRANT_URL")
|
| 13 |
-
login(token=os.getenv("HF_TOKEN"))
|
| 14 |
-
COLLECTION_NAME = "Text2SQL"
|
| 15 |
-
|
| 16 |
-
def retrieve(user_id : str , query : str , connection_url: str) :
|
| 17 |
-
|
| 18 |
-
client = QdrantClient(api_key=qdrant_api , url=qdrant_url)
|
| 19 |
-
|
| 20 |
-
dense_model = TextEmbedding(model_name="sentence-transformers/all-MiniLM-L6-v2")
|
| 21 |
-
sparse_model = SparseTextEmbedding(model_name="Qdrant/bm25")
|
| 22 |
-
|
| 23 |
-
dense_query_vector = list(dense_model.embed([query]))[0]
|
| 24 |
-
|
| 25 |
-
sparse_query = list(sparse_model.embed([query]))[0]
|
| 26 |
-
|
| 27 |
-
sparse_query_vector = models.SparseVector(indices=sparse_query.indices,
|
| 28 |
-
values=sparse_query.values)
|
| 29 |
-
|
| 30 |
-
user_filter = models.Filter(
|
| 31 |
-
must=[
|
| 32 |
-
models.FieldCondition(
|
| 33 |
-
key="user_id",
|
| 34 |
-
match=models.MatchValue(value=user_id)
|
| 35 |
-
)
|
| 36 |
-
]
|
| 37 |
-
)
|
| 38 |
-
|
| 39 |
-
results = client.query_points(
|
| 40 |
-
collection_name=COLLECTION_NAME,
|
| 41 |
-
prefetch=[
|
| 42 |
-
models.Prefetch(
|
| 43 |
-
query=dense_query_vector,
|
| 44 |
-
limit=10,
|
| 45 |
-
using="dense",
|
| 46 |
-
filter=user_filter
|
| 47 |
-
),
|
| 48 |
-
models.Prefetch(
|
| 49 |
-
query=sparse_query_vector,
|
| 50 |
-
using="sparse",
|
| 51 |
-
limit=10,
|
| 52 |
-
filter=user_filter
|
| 53 |
-
)
|
| 54 |
-
],
|
| 55 |
-
query=models.FusionQuery(fusion=models.Fusion.RRF),
|
| 56 |
-
limit=10
|
| 57 |
-
)
|
| 58 |
-
|
| 59 |
-
tables = []
|
| 60 |
-
for point in results.points :
|
| 61 |
-
table = point.payload['table_name']
|
| 62 |
-
if table not in tables :
|
| 63 |
-
tables.append(table)
|
| 64 |
-
|
| 65 |
-
db = SQLDatabase.from_uri(connection_url , sample_rows_in_table_info=0)
|
| 66 |
-
|
| 67 |
-
dialect = db.dialect
|
| 68 |
-
|
| 69 |
-
final_schemes = f"Dialect : {dialect}\n {db.get_table_info(table_names=tables)}"
|
| 70 |
-
|
| 71 |
-
return final_schemes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/tools.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_chroma import Chroma
|
| 2 |
+
from langchain_huggingface import HuggingFaceEmbeddings
|
| 3 |
+
from langchain_community.retrievers import BM25Retriever
|
| 4 |
+
from langchain_classic.retrievers import EnsembleRetriever
|
| 5 |
+
from langchain_core.documents import Document
|
| 6 |
+
from langchain_community.utilities import SQLDatabase
|
| 7 |
+
from langchain_core.tools import tool
|
| 8 |
+
from langgraph.runtime import get_runtime
|
| 9 |
+
import re
|
| 10 |
+
import chromadb
|
| 11 |
+
import os
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
|
| 14 |
+
load_dotenv()
|
| 15 |
+
|
| 16 |
+
COLLECTION_NAME = "Text2SQL"
|
| 17 |
+
|
| 18 |
+
chroma_client = chromadb.CloudClient(
|
| 19 |
+
api_key=os.getenv("CHROMA_API_KEY"),
|
| 20 |
+
tenant=os.getenv("CHROMA_TENANT"),
|
| 21 |
+
database=os.getenv("CHROMA_DATABASE"),
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
BLOCKED_KEYWORDS = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER","TRUNCATE", "CREATE", "GRANT", "REVOKE", "REPLACE"]
|
| 25 |
+
|
| 26 |
+
embedding_model = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
|
| 27 |
+
|
| 28 |
+
vectorstore = Chroma(collection_name=COLLECTION_NAME,embedding_function=embedding_model,client=chroma_client)
|
| 29 |
+
|
| 30 |
+
_bm25_cache = {}
|
| 31 |
+
|
| 32 |
+
def execution_guardrail(sql_query: str) -> bool :
|
| 33 |
+
query = sql_query.strip().rstrip(";")
|
| 34 |
+
|
| 35 |
+
if ";" in query :
|
| 36 |
+
return False
|
| 37 |
+
|
| 38 |
+
if not query.upper().startswith("SELECT") :
|
| 39 |
+
return False
|
| 40 |
+
|
| 41 |
+
for keyword in BLOCKED_KEYWORDS :
|
| 42 |
+
if re.search(rf"\b{keyword}\b" , query , re.IGNORECASE) :
|
| 43 |
+
return False
|
| 44 |
+
|
| 45 |
+
return True
|
| 46 |
+
|
| 47 |
+
@tool
|
| 48 |
+
def retrieve(query: str) -> str:
|
| 49 |
+
"""Retrieve the relevant database schema (tables and columns) needed to answer the user's question."""
|
| 50 |
+
|
| 51 |
+
runtime = get_runtime()
|
| 52 |
+
user_id = runtime.context.user_id
|
| 53 |
+
connection_url = runtime.context.connection_url
|
| 54 |
+
|
| 55 |
+
semantic_retriever = vectorstore.as_retriever(search_kwargs={"k" : 10 , "filter" : {"user_id" : user_id}})
|
| 56 |
+
|
| 57 |
+
if user_id not in _bm25_cache :
|
| 58 |
+
user_docs_raw = vectorstore.get(where={"user_id" : user_id})
|
| 59 |
+
|
| 60 |
+
user_documents = [Document(page_content=text , metadata=meta)for text , meta in zip(user_docs_raw['documents'] , user_docs_raw['metadatas'])]
|
| 61 |
+
|
| 62 |
+
bm25_retriever = BM25Retriever.from_documents(user_documents)
|
| 63 |
+
bm25_retriever.k = 10
|
| 64 |
+
|
| 65 |
+
_bm25_cache[user_id] = bm25_retriever
|
| 66 |
+
|
| 67 |
+
bm25_retriever = _bm25_cache[user_id]
|
| 68 |
+
|
| 69 |
+
ensemble_retriever = EnsembleRetriever(retrievers=[semantic_retriever , bm25_retriever],weights=[0.5 , 0.5])
|
| 70 |
+
|
| 71 |
+
results = ensemble_retriever.invoke(query)
|
| 72 |
+
|
| 73 |
+
tables = []
|
| 74 |
+
for doc in results :
|
| 75 |
+
table = doc.metadata['table_name']
|
| 76 |
+
if table not in tables :
|
| 77 |
+
tables.append(table)
|
| 78 |
+
|
| 79 |
+
db = SQLDatabase.from_uri(connection_url , sample_rows_in_table_info=0)
|
| 80 |
+
|
| 81 |
+
dialect = db.dialect
|
| 82 |
+
|
| 83 |
+
final_schemes = f"Dialect : {dialect}\n {db.get_table_info(table_names=tables)}\n"
|
| 84 |
+
|
| 85 |
+
return final_schemes
|
| 86 |
+
|
| 87 |
+
@tool
|
| 88 |
+
def execute_query(sql_query: str) -> str :
|
| 89 |
+
"""Execute a validated, read-only SQL SELECT query against the connected database and return the raw results."""
|
| 90 |
+
|
| 91 |
+
runtime = get_runtime()
|
| 92 |
+
connection_url = runtime.context.connection_url
|
| 93 |
+
|
| 94 |
+
if not execution_guardrail(sql_query) :
|
| 95 |
+
return "Error: This query was blocked. Only a single SELECT statement is allowed — no INSERT, UPDATE, DELETE, DROP, ALTER, or multi-statement queries."
|
| 96 |
+
|
| 97 |
+
db = SQLDatabase.from_uri(connection_url)
|
| 98 |
+
|
| 99 |
+
try :
|
| 100 |
+
result = db.run(sql_query)
|
| 101 |
+
return result
|
| 102 |
+
except Exception as e :
|
| 103 |
+
return f"Error : {str(e)}"
|
uv.lock
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|