Skip to content

Commit 4ba3d93

Browse files
authored
Merge pull request #28 from ChingEnLin/dev
Release
2 parents 3223684 + d26c652 commit 4ba3d93

24 files changed

Lines changed: 1609 additions & 349 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ jobs:
5050
5151
- name: Run tests with pytest
5252
working-directory: ./backend
53+
env:
54+
GEMINI_API_KEY: "dummy-key-for-tests"
5355
run: |
5456
PYTHONPATH=. pytest --cov=. --cov-report=term-missing --cov-report=xml --verbose
5557

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,33 @@ QueryPal follows a secure **Backend-for-Frontend (BFF)** pattern with enterprise
128128
└─────────────────────┘
129129
```
130130

131+
### Autonomous Query Generation with ReAct Agent
132+
133+
QueryPal employs a powerful LangGraph-based ReAct (Reasoning and Acting) agent to autonomously generate, sandbox-test, and evaluate MongoDB queries. This ensures unparalleled accuracy while maintaining strict safety boundaries for write operations.
134+
135+
```mermaid
136+
graph TD
137+
A[User Intent] -->|Natural Language| B(Generate Query)
138+
B --> C{Write Operation?}
139+
C -- Yes --> D[Return Query to User]
140+
D --> E((Manual Review & Run))
141+
E --> F[Execute Write]
142+
F --> G[Log to Audit Database]
143+
F --> H[Evaluate with AI]
144+
145+
C -- No (Read Only) --> I(Sandbox Execution)
146+
I --> J{Evaluate Result}
147+
J -- Success --> K[Return Final Query & Data]
148+
J -- Failure/Error --> B
149+
150+
subgraph LangGraph Agent Loop
151+
B
152+
C
153+
I
154+
J
155+
end
156+
```
157+
131158
**Security Features:**
132159
-**Zero-Trust Architecture**: No secrets stored in frontend
133160
- 🔐 **Token-Based Authentication**: MSAL with automatic token refresh

backend/models/data_documents.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ class DataDocumentsFilter(BaseModel):
66
key: str
77
value: Any = "" # Can be str, int, etc. depending on the field type
88
operator: Optional[str] = "equals" # 'equals', 'exists', 'not_exists'
9+
type: Optional[str] = "string"
910

1011

1112
class DataDocumentsRequest(BaseModel):
@@ -18,6 +19,10 @@ class DataDocumentsRequest(BaseModel):
1819
filters: Optional[List[DataDocumentsFilter]] = None
1920

2021

22+
class DataDocumentsQueryResponse(BaseModel):
23+
query_code: str
24+
25+
2126
class DataDocumentsResponse(BaseModel):
2227
documents: List[Dict[str, Any]]
2328
currentPage: int

backend/models/schemas.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import BaseModel
1+
from pydantic import BaseModel, Field
22

33

44
class AccountDetailsRequest(BaseModel):
@@ -36,6 +36,9 @@ class QueryPrompt(BaseModel):
3636
intermediate_context: object | None = (
3737
None # Optional intermediate context for complex queries
3838
)
39+
max_iterations: int = Field(
40+
default=3, ge=1, le=10
41+
) # Server-enforced agent iteration cap
3942

4043

4144
class GeneratedCode(BaseModel):
@@ -74,3 +77,15 @@ class Relationship(BaseModel):
7477

7578
class SchemaRelationshipsResponse(BaseModel):
7679
relationships: list[Relationship]
80+
81+
82+
class EvaluateWriteRequest(BaseModel):
83+
user_intent: str
84+
query_code: str
85+
write_result: dict
86+
account_id: str
87+
database_name: str
88+
89+
90+
class EvaluateWriteResponse(BaseModel):
91+
evaluation: str

backend/requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ psycopg2-binary
88
requests
99
pymongo
1010
msal
11+
langgraph
12+
langchain-google-genai
13+
langchain-core
1114

1215
# Testing dependencies
1316
pytest

backend/routes/data_documents.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from models.data_documents import (
55
DataDocumentsRequest,
66
DataDocumentsResponse,
7+
DataDocumentsQueryResponse,
78
FindByIdRequest,
89
FindByIdResponse,
910
UpdateDocumentRequest,
@@ -17,6 +18,8 @@
1718
from services.data_documents_service import (
1819
find_document_by_id,
1920
fetch_documents,
21+
build_mongo_query,
22+
generate_mongo_query_string,
2023
update_document,
2124
get_single_document,
2225
insert_document,
@@ -50,6 +53,33 @@ def get_documents(
5053
)
5154

5255

56+
@router.post("/documents/query_code", response_model=DataDocumentsQueryResponse)
57+
def get_documents_query_code(
58+
body: DataDocumentsRequest = Body(...), authorization: str = Header(...)
59+
):
60+
if not authorization.startswith("Bearer "):
61+
raise HTTPException(status_code=401, detail="Invalid token format")
62+
user_token = authorization.replace("Bearer ", "")
63+
access_token = exchange_token_obo(user_token)
64+
connection_string = get_connection_string(body.account_id, access_token)
65+
66+
# Needs to initialize MongoClient briefly to access find_one if 'all' keys are used in the algorithm
67+
from pymongo import MongoClient
68+
69+
client = MongoClient(connection_string)
70+
db = client[body.database_name]
71+
collection = db[body.collection_name]
72+
73+
query = build_mongo_query(
74+
collection=collection,
75+
filter=body.filter.model_dump() if body.filter else None,
76+
filters=[f.model_dump() for f in body.filters] if body.filters else None,
77+
)
78+
79+
query_str = generate_mongo_query_string(body.collection_name, query)
80+
return DataDocumentsQueryResponse(query_code=query_str)
81+
82+
5383
@router.put("/documents", response_model=dict)
5484
def put_update_document(
5585
body: UpdateDocumentRequest = Body(...), authorization: str = Header(...)

backend/routes/query.py

Lines changed: 126 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
DebugSuggestionResponse,
1111
SchemaRelationshipsRequest,
1212
SchemaRelationshipsResponse,
13+
EvaluateWriteRequest,
14+
EvaluateWriteResponse,
1315
)
16+
from services.react_agent_service import run_query_generator
1417
from services.gemini_service import (
15-
generate_query_from_prompt,
1618
generate_suggestion_from_query_error,
1719
generate_schema_relationships,
1820
)
@@ -23,10 +25,46 @@
2325
)
2426
from models.analyze import AnalyzeRequest, AnalyzeResponse
2527
from services.analyze_service import analyze_query_result
28+
from services.evaluate_write_service import evaluate_write_result
29+
from services.data_documents_service import log_write_operation
30+
from services.user_queries_service import get_user_id_from_token
31+
from pymongo.results import (
32+
UpdateResult,
33+
InsertOneResult,
34+
InsertManyResult,
35+
DeleteResult,
36+
)
37+
import ast
38+
import re
2639

2740
router = APIRouter()
2841

2942

43+
def extract_collection_name(query_str: str) -> str:
44+
"""Extract collection name from a PyMongo query string using AST."""
45+
try:
46+
tree = ast.parse(query_str)
47+
for node in ast.walk(tree):
48+
# Match: db["collection"] or db['collection']
49+
if (
50+
isinstance(node, ast.Subscript)
51+
and isinstance(node.value, ast.Name)
52+
and node.value.id == "db"
53+
):
54+
if isinstance(node.slice, ast.Constant):
55+
return str(node.slice.value)
56+
# Match: db.collection
57+
if (
58+
isinstance(node, ast.Attribute)
59+
and isinstance(node.value, ast.Name)
60+
and node.value.id == "db"
61+
):
62+
return node.attr
63+
except SyntaxError:
64+
pass
65+
return "unknown"
66+
67+
3068
@router.post("/nl2query")
3169
def nl2query(prompt: QueryPrompt = Body(...), authorization: str = Header(...)):
3270
if not authorization.startswith("Bearer "):
@@ -51,18 +89,24 @@ def nl2query(prompt: QueryPrompt = Body(...), authorization: str = Header(...)):
5189
schema_summary = get_database_schema_summary(
5290
prompt.account_id, prompt.db_context.name, access_token
5391
)
92+
connection_string = get_connection_string(prompt.account_id, access_token)
5493
except Exception as e:
55-
print(f"Error fetching schema context: {e}")
94+
print(
95+
f"[ERROR] Failed to fetch schema/connection for account {prompt.account_id}: {e}"
96+
)
5697
schema_summary = "Could not fetch schema summary."
98+
connection_string = ""
5799

58100
collections = [col.name for col in prompt.db_context.collections]
59-
return generate_query_from_prompt(
60-
prompt.user_input,
61-
collections,
62-
prompt.db_context.name,
63-
collection_context=None,
101+
102+
return run_query_generator(
103+
user_input=prompt.user_input,
104+
database=prompt.db_context.name,
105+
collections=collections,
106+
schema_context=schema_summary,
64107
intermediate_context=prompt.intermediate_context,
65-
all_collections_schema=schema_summary,
108+
connection_string=connection_string,
109+
max_iterations=prompt.max_iterations,
66110
)
67111

68112

@@ -106,6 +150,50 @@ def execute(query: ExecuteInput = Body(...), authorization: str = Header(...)):
106150
status_code=500,
107151
detail=f"MongoDB query error: {result['error']} ({result['exception_type']})",
108152
)
153+
154+
# LOG WRITE OPERATIONS
155+
if isinstance(
156+
result, (UpdateResult, InsertOneResult, InsertManyResult, DeleteResult)
157+
):
158+
try:
159+
user_email = get_user_id_from_token(user_token)
160+
161+
operation = "query_generator"
162+
if isinstance(result, UpdateResult):
163+
operation = "update"
164+
elif isinstance(result, (InsertOneResult, InsertManyResult)):
165+
operation = "insert"
166+
elif isinstance(result, DeleteResult):
167+
operation = "delete"
168+
169+
collection = extract_collection_name(query.query)
170+
171+
match = re.search(r"//([^:@]+)", connection_string)
172+
account_name = match.group(1) if match else "unknown"
173+
account_database = f"{account_name}.{query.database_name}"
174+
175+
transformed_res = transform_mongo_result(result)
176+
query_info = {
177+
"query": query.query,
178+
"source": "query_generator",
179+
"result": transformed_res,
180+
}
181+
182+
before_data = query_info if operation == "delete" else None
183+
after_data = query_info if operation in ["update", "insert"] else None
184+
185+
log_write_operation(
186+
user_email=user_email,
187+
operation=operation,
188+
database_name=account_database,
189+
collection_name=collection,
190+
document_id="query_generator",
191+
before_data=before_data,
192+
after_data=after_data,
193+
)
194+
except Exception as e:
195+
print(f"Error logging query generator write: {e}")
196+
109197
return transform_mongo_result(result)
110198

111199

@@ -123,3 +211,33 @@ def analyze(body: AnalyzeRequest = Body(...)):
123211
Sends a query result to the AI for analysis and visualization suggestions.
124212
"""
125213
return analyze_query_result(body.query_result)
214+
215+
216+
@router.post("/evaluate-write", response_model=EvaluateWriteResponse)
217+
def evaluate_write(
218+
body: EvaluateWriteRequest = Body(...), authorization: str = Header(...)
219+
):
220+
"""
221+
Evaluates the result of a write operation against the user's original intent.
222+
Requires authentication via Bearer token.
223+
"""
224+
if not authorization.startswith("Bearer "):
225+
raise HTTPException(status_code=401, detail="Invalid token format")
226+
227+
user_token = authorization.replace("Bearer ", "")
228+
access_token = exchange_token_obo(user_token)
229+
try:
230+
connection_string = get_connection_string(body.account_id, access_token)
231+
except Exception as e:
232+
print(
233+
f"[ERROR] Failed to fetch connection string for account {body.account_id}: {e}"
234+
)
235+
connection_string = ""
236+
237+
return evaluate_write_result(
238+
user_intent=body.user_intent,
239+
query_code=body.query_code,
240+
write_result=body.write_result,
241+
connection_string=connection_string,
242+
database_name=body.database_name,
243+
)

0 commit comments

Comments
 (0)