LightRT commited on
Commit
0fb7e57
·
1 Parent(s): b09c610

Fixed Langgraph Workflow

Browse files
Files changed (9) hide show
  1. .gitignore +2 -1
  2. app.py +109 -0
  3. pyproject.toml +5 -0
  4. src/embedding.py +18 -61
  5. src/graph.py +142 -218
  6. src/main.py +72 -57
  7. src/retrieval.py +0 -71
  8. src/tools.py +103 -0
  9. uv.lock +0 -0
.gitignore CHANGED
@@ -5,4 +5,5 @@ __pycache__/
5
  .env/
6
  venv/.env
7
  .env
8
- app.py
 
 
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 qdrant_client import QdrantClient
2
- from qdrant_client.models import Distance, VectorParams, SparseVectorParams, PointStruct
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
- from huggingface_hub import login
9
-
10
- COLLECTION_NAME = "Text2SQL"
11
 
12
  load_dotenv()
13
 
14
- qdrant_api = os.getenv("QDRANT_API_KEY")
15
- qdrant_url = os.getenv("QDRANT_URL")
16
- hf_token = os.getenv("HF_TOKEN")
17
 
18
- def create_embeddings(connection_url : str , user_id : str) :
19
- client = QdrantClient(api_key=qdrant_api , url=qdrant_url)
 
 
 
20
 
21
- dense_model = TextEmbedding(model_name="sentence-transformers/all-MiniLM-L6-v2")
22
- sparse_model = SparseTextEmbedding(model_name="Qdrant/bm25")
23
 
24
- if not client.collection_exists(COLLECTION_NAME) :
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
- point = PointStruct(
59
- id = table_id ,
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
- points.append(point)
72
 
73
- client.upsert(collection_name=COLLECTION_NAME, points=points)
 
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 langgraph.graph import StateGraph, START ,END
9
- from pydantic import BaseModel , Field
10
- import datetime
11
- from langchain_community.utilities import SQLDatabase
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-4o-mini",
29
- openai_api_key=os.getenv("OPENROUTER_API_KEY"),
30
- openai_api_base="https://openrouter.ai/api/v1",
31
- temperature=0
32
  )
33
 
34
- class sql_query(BaseModel) :
35
- generated_sql_query : str = Field(...,description="The raw, valid executable SQL query text. Contain absolutely NO markdown wrapping, code blocks, or conversational formatting.")
36
-
37
- def retrieve_node(state : State) :
38
- messages = state.get("messages")
39
- db_url = state.get("connection_url")
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
- return {"query_result" : result , "error" : None , "retry" : 0}
149
-
150
- except Exception as e :
151
- return {'error' : str(e) , "retry" : retry+1}
152
-
153
- def routing(state : State) :
154
- error = state.get("error")
155
- retry = state.get('retry' , 0)
156
-
157
- if error and retry<3 :
158
- return "generate_node"
159
- else :
160
- return "answer_node"
161
-
162
- def answer_node(state : State) :
163
- messages = state.get("messages")
164
- query_result = state.get("query_result" , "No records found.")
165
- sql_query = state.get("sql_query", "")
166
- error = state.get("error")
167
-
168
- history_messages = messages[:-1]
169
- user_query = messages[-1].content
170
-
171
- if history_messages:
172
- history_text = "\n".join([
173
- f"{msg.type.capitalize()}: {msg.content}"
174
- for msg in history_messages
175
- ])
176
- else:
177
- history_text = "This is the first user request. No history exists."
178
-
179
- system_prompt = f"""
180
- You are a helpful Data Analyst communicating directly with a user.
181
-
182
- === CONVERSATION HISTORY ===
183
- {history_text}
184
-
185
- === EXECUTION CONTEXT ===
186
- SQL QUERY USED:
187
- {sql_query}
188
-
189
- RAW DATABASE RESULT:
190
- {query_result}
191
-
192
- === INSTRUCTIONS ===
193
- 1. Use ONLY the returned data.
194
- 2. Interpret the result using the SQL query and its selected aliases.
195
- 3. If the query selected columns like customer_id, customer_name, total_amount, use those exact labels in the final response.
196
- 4. If the result is positional, map values to the SQL SELECT order.
197
- 5. Never invent a name or ID.
198
- 6. For who/which questions:
199
- - prefer name + id
200
- - if name is missing, give the id and clearly say no readable name was returned
201
- 7. If the result contains an ID and a value like total_amount, explain them clearly.
202
- 8. Do not mention SQL or the database in the final answer.
203
- 9. Give a clean, professional response.
204
- """
205
-
206
- final_msg = [
207
- SystemMessage(content=system_prompt),
208
- HumanMessage(content=f"LATEST USER REQUEST:\n{user_query}")
209
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
- response = llm.invoke(final_msg)
212
-
213
- return {"messages": [response], "final_result": response.content}
214
-
215
- workflow = StateGraph(State)
216
-
217
- workflow.add_node("retrieve_node" , retrieve_node)
218
- workflow.add_node("generate_node" , generate_node)
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 , BackgroundTasks
2
  from src.embedding import create_embeddings
3
- from src.graph import workflow
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 UploadRequest(BaseModel):
19
- connection_url: str = Field(..., description="Database URL")
20
- user_id: str = Field(..., description="The unique identifier for the tenant context.")
 
 
21
 
22
- class ChatRequest(BaseModel) :
23
- message : str = Field(...,description="Input message by the user.")
24
- thread_id : str = Field(...,description="Unique session ID to maintain short term memory.")
25
- user_id : str = Field(...,description="The unique identifier for the tenant context.")
26
- connection_url : str = Field(...,description="Database URL")
27
 
28
- @app.post("/upload_url" , summary="Recieve database URL and invoke ingestion pipeline.")
29
- def upload(request : UploadRequest , background_tasks : BackgroundTasks) :
30
- background_tasks.add_task(create_embeddings , request.connection_url , request.user_id)
 
 
 
 
31
 
32
  return {
33
- "status" : "success",
34
- "message" : "Ingestion Pipeline started !"
35
  }
36
 
37
- @app.post("/chat" , summary="Return the response generated by the agent for the given user query.")
38
- def chat_endpoint(request : ChatRequest) :
39
- db_uri = os.getenv("DATABASE_URI")
40
-
41
- print(f"DATABASE_URI = {repr(db_uri)}", flush=True)
42
 
43
- with PostgresSaver.from_conn_string(db_uri) as checkpointer:
44
- checkpointer.setup()
45
 
46
- agent = workflow.compile(
47
- checkpointer=checkpointer
 
 
 
48
  )
49
- config = {
50
- "configurable" : {
51
- 'thread_id' : request.thread_id
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
- except Exception as e :
78
- raise HTTPException(status_code=500 , detail=f"Error : {str(e)}")
 
 
 
 
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