Skip to content

Commit 1e82c19

Browse files
authored
Merge pull request #350 from FalkorDB/staging
Staging
2 parents c74a244 + 8427655 commit 1e82c19

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+2706
-1000
lines changed

.github/workflows/playwright.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ on:
55
pull_request:
66
branches: [ main, staging ]
77

8+
concurrency:
9+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
10+
cancel-in-progress: true
11+
812
permissions:
913
contents: read
1014

.github/workflows/tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ on:
66
pull_request:
77
branches: [ main, staging ]
88

9+
concurrency:
10+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
11+
cancel-in-progress: true
12+
913
permissions:
1014
contents: read
1115

Pipfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ name = "pypi"
55

66
[packages]
77
fastapi = "~=0.124.0"
8-
uvicorn = "~=0.38.0"
8+
uvicorn = "~=0.40.0"
99
litellm = "~=1.80.9"
1010
falkordb = "~=1.2.2"
1111
psycopg2-binary = "~=2.9.11"
@@ -22,7 +22,7 @@ fastmcp = ">=2.13.1"
2222
[dev-packages]
2323
pytest = "~=8.4.2"
2424
pylint = "~=4.0.3"
25-
playwright = "~=1.56.0"
25+
playwright = "~=1.57.0"
2626
pytest-playwright = "~=0.7.1"
2727
pytest-asyncio = "~=1.2.0"
2828

Pipfile.lock

Lines changed: 136 additions & 135 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Connect and ask questions: [![Discord](https://img.shields.io/badge/Discord-%235
1313
[![Swagger UI](https://img.shields.io/badge/API-Swagger-11B48A?logo=swagger&logoColor=white)](https://app.queryweaver.ai/docs)
1414
</div>
1515

16-
![queryweaver-demo-video-ui](https://github.com/user-attachments/assets/b66018cb-0e42-4907-8ac1-c169762ff22d)
16+
![new-qw-ui-gif](https://github.com/user-attachments/assets/87bb6a50-5bf4-4217-ad05-f99e32ed2dd0)
1717

1818
## Get Started
1919
### Docker

api/agents/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
from .relevancy_agent import RelevancyAgent
55
from .follow_up_agent import FollowUpAgent
66
from .response_formatter_agent import ResponseFormatterAgent
7+
from .healer_agent import HealerAgent
78
from .utils import parse_response
89

910
__all__ = [
1011
"AnalysisAgent",
1112
"RelevancyAgent",
1213
"FollowUpAgent",
1314
"ResponseFormatterAgent",
15+
"HealerAgent",
1416
"parse_response"
1517
]

api/agents/analysis_agent.py

Lines changed: 161 additions & 78 deletions
Large diffs are not rendered by default.

api/agents/healer_agent.py

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
"""
2+
HealerAgent - Specialized agent for fixing SQL syntax errors.
3+
4+
This agent focuses solely on correcting SQL queries that failed execution,
5+
without requiring full graph context. It uses the error message and the
6+
failed query to generate a corrected version.
7+
"""
8+
# pylint: disable=trailing-whitespace,line-too-long,too-many-arguments
9+
# pylint: disable=too-many-positional-arguments,broad-exception-caught
10+
11+
import re
12+
from typing import Dict, Callable, Any
13+
from litellm import completion
14+
from api.config import Config
15+
from .utils import parse_response
16+
17+
18+
class HealerAgent:
19+
"""Agent specialized in fixing SQL syntax errors."""
20+
21+
def __init__(self, max_healing_attempts: int = 3):
22+
"""Initialize the healer agent.
23+
24+
Args:
25+
max_healing_attempts: Maximum number of healing attempts before giving up
26+
"""
27+
self.max_healing_attempts = max_healing_attempts
28+
self.messages = []
29+
30+
@staticmethod
31+
def validate_sql_syntax(sql_query: str) -> dict:
32+
"""
33+
Validate SQL query for basic syntax errors.
34+
Similar to CypherValidator in the text-to-cypher PR.
35+
36+
Args:
37+
sql_query: The SQL query to validate
38+
39+
Returns:
40+
dict with 'is_valid', 'errors', and 'warnings' keys
41+
"""
42+
errors = []
43+
warnings = []
44+
45+
query = sql_query.strip()
46+
47+
# Check if query is empty
48+
if not query:
49+
errors.append("Query is empty")
50+
return {"is_valid": False, "errors": errors, "warnings": warnings}
51+
52+
# Check for basic SQL keywords
53+
query_upper = query.upper()
54+
has_sql_keywords = any(
55+
kw in query_upper for kw in ["SELECT", "INSERT", "UPDATE", "DELETE", "WITH", "CREATE"]
56+
)
57+
if not has_sql_keywords:
58+
errors.append("Query does not contain valid SQL keywords")
59+
60+
# Check for dangerous operations (for dev/test safety)
61+
dangerous_patterns = [
62+
r'\bDROP\s+TABLE\b', r'\bTRUNCATE\b', r'\bDELETE\s+FROM\s+\w+\s*;?\s*$'
63+
]
64+
for pattern in dangerous_patterns:
65+
if re.search(pattern, query_upper):
66+
warnings.append(f"Query contains potentially dangerous operation: {pattern}")
67+
68+
# Check for balanced parentheses
69+
paren_count = 0
70+
for char in query:
71+
if char == '(':
72+
paren_count += 1
73+
elif char == ')':
74+
paren_count -= 1
75+
if paren_count < 0:
76+
errors.append("Unbalanced parentheses in query")
77+
break
78+
if paren_count != 0:
79+
errors.append("Unbalanced parentheses in query")
80+
81+
# Check for SELECT queries have proper structure
82+
if query_upper.startswith("SELECT") or "SELECT" in query_upper:
83+
if "FROM" not in query_upper and "DUAL" not in query_upper:
84+
warnings.append("SELECT query missing FROM clause")
85+
86+
return {
87+
"is_valid": len(errors) == 0,
88+
"errors": errors,
89+
"warnings": warnings
90+
}
91+
92+
def _build_healing_prompt(
93+
self,
94+
failed_sql: str,
95+
error_message: str,
96+
db_description: str,
97+
question: str,
98+
database_type: str
99+
) -> str:
100+
"""Build a focused prompt for SQL query healing."""
101+
102+
# Analyze error to provide targeted hints
103+
error_hints = self._analyze_error(error_message, database_type)
104+
105+
prompt = f"""You are a SQL query debugging expert. Your task is to fix a SQL query that failed execution.
106+
107+
DATABASE TYPE: {database_type.upper()}
108+
109+
FAILED SQL QUERY:
110+
```sql
111+
{failed_sql}
112+
```
113+
114+
EXECUTION ERROR:
115+
{error_message}
116+
117+
{f"ORIGINAL QUESTION: {question}" if question else ""}
118+
119+
{f"DATABASE INFO: {db_description}"}
120+
121+
COMMON ERROR PATTERNS:
122+
{error_hints}
123+
124+
YOUR TASK:
125+
1. Identify the exact cause of the error
126+
2. Fix ONLY what's broken - don't rewrite the entire query
127+
3. Ensure the fix is compatible with {database_type.upper()}
128+
4. Maintain the original query logic and intent
129+
130+
CRITICAL RULES FOR {database_type.upper()}:
131+
"""
132+
133+
if database_type == "sqlite":
134+
prompt += """
135+
- SQLite does NOT support EXTRACT() function - use strftime() instead
136+
* EXTRACT(YEAR FROM date_col) → strftime('%Y', date_col)
137+
* EXTRACT(MONTH FROM date_col) → strftime('%m', date_col)
138+
* EXTRACT(DAY FROM date_col) → strftime('%d', date_col)
139+
- SQLite column/table names are case-insensitive BUT must exist
140+
- SQLite uses double quotes "column" for identifiers with special characters
141+
- Use backticks `column` for compatibility
142+
- No schema qualifiers (database.table.column)
143+
"""
144+
elif database_type == "postgresql":
145+
prompt += """
146+
- PostgreSQL is case-sensitive - use double quotes for mixed-case identifiers
147+
- EXTRACT() is supported: EXTRACT(YEAR FROM date_col)
148+
- Column references must match exact case when quoted
149+
"""
150+
151+
prompt += """
152+
RESPONSE FORMAT (valid JSON only):
153+
{
154+
"sql_query": "-- your fixed SQL query here",
155+
"confidence": 85,
156+
"explanation": "Brief explanation of what was fixed",
157+
"changes_made": ["Changed EXTRACT to strftime", "Fixed column casing"]
158+
}
159+
160+
IMPORTANT:
161+
- Return ONLY the JSON object, no other text
162+
- Fix ONLY the specific error, preserve the rest
163+
- Test your fix mentally before responding
164+
- If error is about a column/table name, check spelling carefully
165+
"""
166+
167+
return prompt
168+
169+
def heal_and_execute( # pylint: disable=too-many-locals
170+
self,
171+
initial_sql: str,
172+
initial_error: str,
173+
execute_sql_func: Callable[[str], Any],
174+
db_description: str = "",
175+
question: str = "",
176+
database_type: str = "sqlite"
177+
) -> Dict[str, Any]:
178+
"""Iteratively heal and execute SQL query until success or max attempts.
179+
180+
This method creates a conversation loop between the healer and the database:
181+
1. Build initial prompt once with the failed SQL and error (including syntax validation)
182+
2. Loop: Call LLM → Parse healed SQL → Execute → Check if successful
183+
3. If successful, return results
184+
4. If failed and not last attempt, add error feedback and repeat
185+
5. If failed on last attempt, return failure
186+
187+
Args:
188+
initial_sql: The initial SQL query that failed
189+
initial_error: The error message from the initial execution failure
190+
execute_sql_func: Function that executes SQL and returns results or raises exception
191+
db_description: Optional database description
192+
question: Optional original question
193+
database_type: Type of database (sqlite, postgresql, mysql, etc.)
194+
195+
Returns:
196+
Dict containing:
197+
- success: Whether healing succeeded
198+
- sql_query: Final SQL query (healed or original)
199+
- query_results: Results from successful execution (if success=True)
200+
- attempts: Number of healing attempts made
201+
- final_error: Final error message (if success=False)
202+
"""
203+
self.messages = []
204+
205+
# Validate SQL syntax for additional error context
206+
validation_result = self.validate_sql_syntax(initial_sql)
207+
additional_context = ""
208+
if validation_result["errors"]:
209+
additional_context += f"\nSyntax errors: {', '.join(validation_result['errors'])}"
210+
if validation_result["warnings"]:
211+
additional_context += f"\nWarnings: {', '.join(validation_result['warnings'])}"
212+
# Enhance error message with validation context
213+
enhanced_error = initial_error + additional_context
214+
215+
# Build initial prompt once before the loop
216+
prompt = self._build_healing_prompt(
217+
failed_sql=initial_sql,
218+
error_message=enhanced_error,
219+
db_description=db_description,
220+
question=question,
221+
database_type=database_type
222+
)
223+
self.messages.append({"role": "user", "content": prompt})
224+
225+
for attempt in range(self.max_healing_attempts):
226+
# Call LLM
227+
response = completion(
228+
model=Config.COMPLETION_MODEL,
229+
messages=self.messages,
230+
temperature=0.1,
231+
max_tokens=2000
232+
)
233+
234+
content = response.choices[0].message.content
235+
self.messages.append({"role": "assistant", "content": content})
236+
237+
# Parse response
238+
result = parse_response(content)
239+
healed_sql = result.get("sql_query", "")
240+
241+
# Execute against database
242+
error = None
243+
try:
244+
query_results = execute_sql_func(healed_sql)
245+
except Exception as e:
246+
error = str(e)
247+
248+
# Check if it worked
249+
if error is None:
250+
# Success!
251+
return {
252+
"success": True,
253+
"sql_query": healed_sql,
254+
"query_results": query_results,
255+
"attempts": attempt + 1,
256+
"final_error": None
257+
}
258+
259+
# Failed - check if last attempt
260+
if attempt >= self.max_healing_attempts - 1:
261+
return {
262+
"success": False,
263+
"sql_query": healed_sql,
264+
"query_results": None,
265+
"attempts": attempt + 1,
266+
"final_error": error
267+
}
268+
269+
# Not last attempt - add feedback and continue
270+
feedback = f"""The healed query failed with error:
271+
272+
```sql
273+
{healed_sql}
274+
```
275+
276+
ERROR:
277+
{error}
278+
279+
Please fix this error."""
280+
self.messages.append({"role": "user", "content": feedback})
281+
282+
# Fallback return
283+
return {
284+
"success": False,
285+
"sql_query": initial_sql,
286+
"query_results": None,
287+
"attempts": self.max_healing_attempts,
288+
"final_error": initial_error
289+
}
290+
291+
292+
def _analyze_error(self, error_message: str, database_type: str) -> str:
293+
"""Analyze error message and provide targeted hints."""
294+
295+
error_lower = error_message.lower()
296+
hints = []
297+
298+
# Common SQLite errors
299+
if database_type == "sqlite":
300+
if "near \"from\"" in error_lower or "syntax error" in error_lower:
301+
hints.append("⚠️ EXTRACT() is NOT supported in SQLite - use strftime() instead!")
302+
hints.append(" Example: strftime('%Y', date_column) for year")
303+
304+
if "no such column" in error_lower:
305+
hints.append("⚠️ Column name doesn't exist - check spelling and case")
306+
hints.append(" SQLite is case-insensitive but the column must exist")
307+
308+
if "no such table" in error_lower:
309+
hints.append("⚠️ Table name doesn't exist - check spelling")
310+
311+
if "ambiguous column" in error_lower:
312+
hints.append("⚠️ Ambiguous column - use table alias: table.column or alias.column")
313+
314+
# PostgreSQL errors
315+
elif database_type == "postgresql":
316+
if "column" in error_lower and "does not exist" in error_lower:
317+
hints.append("⚠️ Column case mismatch - PostgreSQL is case-sensitive")
318+
hints.append(' Use double quotes for mixed-case: "ColumnName"')
319+
320+
if "relation" in error_lower and "does not exist" in error_lower:
321+
hints.append("⚠️ Table doesn't exist or case mismatch")
322+
323+
# Generic hints if no specific patterns matched
324+
if not hints:
325+
hints.append("⚠️ Check syntax compatibility with " + database_type.upper())
326+
hints.append("⚠️ Verify column and table names exist")
327+
328+
return "\n".join(hints)

0 commit comments

Comments
 (0)