natquery 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- natquery/__init__.py +0 -0
- natquery/cli/__init__.py +0 -0
- natquery/cli/commands.py +256 -0
- natquery/cli/formatter.py +126 -0
- natquery/cli/main.py +51 -0
- natquery/cli/shell.py +135 -0
- natquery/config/__init__.py +0 -0
- natquery/config/connection.py +123 -0
- natquery/config/settings.py +100 -0
- natquery/execution/__init__.py +0 -0
- natquery/execution/engine.py +29 -0
- natquery/execution/explain.py +51 -0
- natquery/llm/__init__.py +0 -0
- natquery/llm/client.py +37 -0
- natquery/llm/self_corrector.py +84 -0
- natquery/ml/__init__.py +0 -0
- natquery/observability/__init__.py +0 -0
- natquery/observability/cost_analyzer.py +79 -0
- natquery/observability/index_recommender.py +58 -0
- natquery/observability/logger.py +109 -0
- natquery/observability/performance_api.py +58 -0
- natquery/orchestration/__init__.py +0 -0
- natquery/orchestration/error_classifier.py +29 -0
- natquery/orchestration/error_handler.py +81 -0
- natquery/orchestration/pipeline.py +192 -0
- natquery/orchestration/workspace.py +44 -0
- natquery/prompt/__init__.py +0 -0
- natquery/prompt/builder.py +27 -0
- natquery/schema/__init__.py +0 -0
- natquery/schema/extractor.py +80 -0
- natquery/schema/formatter.py +52 -0
- natquery/security/__init__.py +0 -0
- natquery/security/validator.py +20 -0
- natquery-1.0.0.dist-info/METADATA +147 -0
- natquery-1.0.0.dist-info/RECORD +39 -0
- natquery-1.0.0.dist-info/WHEEL +5 -0
- natquery-1.0.0.dist-info/entry_points.txt +2 -0
- natquery-1.0.0.dist-info/licenses/LICENSE +21 -0
- natquery-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Settings:
|
|
7
|
+
"""
|
|
8
|
+
Configuration manager:
|
|
9
|
+
Reads configuration from:
|
|
10
|
+
~/.natquery/<active_db>/config.json
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
BASE_DIR = Path.home() / ".natquery"
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def _get_active_db_name(cls) -> str:
|
|
17
|
+
current_file = cls.BASE_DIR / "current_db"
|
|
18
|
+
|
|
19
|
+
if not current_file.exists():
|
|
20
|
+
raise RuntimeError("No active database configured.\nRun: natquery connect")
|
|
21
|
+
|
|
22
|
+
return current_file.read_text().strip()
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def _get_config_path(cls) -> Path:
|
|
26
|
+
db_name = cls._get_active_db_name()
|
|
27
|
+
return cls.BASE_DIR / db_name / "config.json"
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def exists(cls) -> bool:
|
|
31
|
+
return cls._get_config_path().exists()
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def load_config(cls) -> Dict:
|
|
35
|
+
config_path = cls._get_config_path()
|
|
36
|
+
|
|
37
|
+
if not config_path.exists():
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
"NatQuery not configured properly.\nRun: natquery connect"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
with open(config_path, "r") as f:
|
|
43
|
+
return json.load(f)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def get_db_config(cls) -> Dict:
|
|
47
|
+
config = cls.load_config()
|
|
48
|
+
|
|
49
|
+
if config.get("connection_type") == "dsn":
|
|
50
|
+
if "db_dsn" not in config or not config["db_dsn"]:
|
|
51
|
+
raise ValueError("Missing database DSN.")
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
"type": "dsn",
|
|
55
|
+
"dsn": config["db_dsn"],
|
|
56
|
+
"dbname": config.get("db_name", "unknown_db"),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Default: standard connection
|
|
60
|
+
required = [
|
|
61
|
+
"db_host",
|
|
62
|
+
"db_port",
|
|
63
|
+
"db_name",
|
|
64
|
+
"db_user",
|
|
65
|
+
"db_password",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
for key in required:
|
|
69
|
+
if key not in config or not config[key]:
|
|
70
|
+
raise ValueError(f"Missing database config field: {key}")
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"type": "standard",
|
|
74
|
+
"host": config["db_host"],
|
|
75
|
+
"port": config["db_port"],
|
|
76
|
+
"dbname": config["db_name"],
|
|
77
|
+
"user": config["db_user"],
|
|
78
|
+
"password": config["db_password"],
|
|
79
|
+
"sslmode": config.get("sslmode"),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def get_llm_config(cls) -> Dict[str, str]:
|
|
84
|
+
config = cls.load_config()
|
|
85
|
+
|
|
86
|
+
required = [
|
|
87
|
+
"llm_provider",
|
|
88
|
+
"llm_api_key",
|
|
89
|
+
"llm_model",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
for key in required:
|
|
93
|
+
if key not in config or not config[key]:
|
|
94
|
+
raise ValueError(f"Missing LLM config field: {key}")
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
"provider": config["llm_provider"],
|
|
98
|
+
"api_key": config["llm_api_key"],
|
|
99
|
+
"model": config["llm_model"],
|
|
100
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from natquery.config.connection import get_connection, close_connection, get_cursor
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def execute_sql(sql: str):
|
|
5
|
+
conn = get_connection()
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
cursor = get_cursor(conn)
|
|
9
|
+
cursor.execute(sql)
|
|
10
|
+
|
|
11
|
+
if cursor.description:
|
|
12
|
+
rows = cursor.fetchall()
|
|
13
|
+
# RealDictCursor rows are already dict-like, convert to plain dict
|
|
14
|
+
result = [
|
|
15
|
+
(
|
|
16
|
+
dict(row)
|
|
17
|
+
if hasattr(row, "keys")
|
|
18
|
+
else dict(zip([desc[0] for desc in cursor.description], row))
|
|
19
|
+
)
|
|
20
|
+
for row in rows
|
|
21
|
+
]
|
|
22
|
+
else:
|
|
23
|
+
result = []
|
|
24
|
+
|
|
25
|
+
conn.commit()
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
finally:
|
|
29
|
+
close_connection(conn)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from natquery.config.connection import get_connection, close_connection
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def run_explain(query: str):
|
|
5
|
+
"""
|
|
6
|
+
Runs EXPLAIN (FORMAT JSON) on a query.
|
|
7
|
+
Returns the plan as JSON.
|
|
8
|
+
"""
|
|
9
|
+
conn = get_connection()
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
cursor = conn.cursor()
|
|
13
|
+
|
|
14
|
+
explain_query = f"EXPLAIN (FORMAT JSON) {query}"
|
|
15
|
+
cursor.execute(explain_query)
|
|
16
|
+
|
|
17
|
+
result = cursor.fetchone()[0] # JSON result
|
|
18
|
+
|
|
19
|
+
# PostgreSQL returns list with single JSON object
|
|
20
|
+
if isinstance(result, list):
|
|
21
|
+
return result[0]
|
|
22
|
+
|
|
23
|
+
return result
|
|
24
|
+
|
|
25
|
+
finally:
|
|
26
|
+
close_connection(conn)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_explain_analyze(query: str):
|
|
30
|
+
"""
|
|
31
|
+
Runs EXPLAIN ANALYZE (FORMAT JSON) for actual execution metrics.
|
|
32
|
+
Returns the plan with actual execution stats as JSON.
|
|
33
|
+
"""
|
|
34
|
+
conn = get_connection()
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
cursor = conn.cursor()
|
|
38
|
+
|
|
39
|
+
explain_query = f"EXPLAIN (ANALYZE, FORMAT JSON) {query}"
|
|
40
|
+
cursor.execute(explain_query)
|
|
41
|
+
|
|
42
|
+
result = cursor.fetchone()[0] # JSON result
|
|
43
|
+
|
|
44
|
+
# PostgreSQL returns list with single JSON object
|
|
45
|
+
if isinstance(result, list):
|
|
46
|
+
return result[0]
|
|
47
|
+
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
finally:
|
|
51
|
+
close_connection(conn)
|
natquery/llm/__init__.py
ADDED
|
File without changes
|
natquery/llm/client.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from groq import Groq
|
|
2
|
+
from natquery.config.settings import Settings
|
|
3
|
+
from natquery.schema.formatter import format_schema
|
|
4
|
+
from natquery.prompt.builder import build_prompt
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def generate_sql(user_query: str) -> str:
|
|
10
|
+
|
|
11
|
+
llm_config = Settings.get_llm_config()
|
|
12
|
+
client = Groq(api_key=llm_config["api_key"])
|
|
13
|
+
|
|
14
|
+
db_config = Settings.get_db_config()
|
|
15
|
+
db_name = db_config["dbname"]
|
|
16
|
+
|
|
17
|
+
schema_path = Path.home() / ".natquery" / db_name / "schema.json"
|
|
18
|
+
|
|
19
|
+
if schema_path.exists():
|
|
20
|
+
with open(schema_path, "r") as f:
|
|
21
|
+
schema = json.load(f)
|
|
22
|
+
else:
|
|
23
|
+
schema = {}
|
|
24
|
+
|
|
25
|
+
schema_text = format_schema(schema)
|
|
26
|
+
messages = build_prompt(user_query, schema_text)
|
|
27
|
+
|
|
28
|
+
response = client.chat.completions.create(
|
|
29
|
+
model=llm_config["model"],
|
|
30
|
+
messages=messages,
|
|
31
|
+
temperature=0.0,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
sql = response.choices[0].message.content.strip()
|
|
35
|
+
sql = sql.replace("```sql", "").replace("```", "").strip()
|
|
36
|
+
|
|
37
|
+
return sql
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from groq import Groq
|
|
2
|
+
from natquery.config.settings import Settings
|
|
3
|
+
from natquery.schema.formatter import format_schema
|
|
4
|
+
from natquery.orchestration.error_classifier import classify_error
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def correct_sql(user_query: str, failed_sql: str, error_message: str) -> str:
|
|
10
|
+
|
|
11
|
+
llm_config = Settings.get_llm_config()
|
|
12
|
+
client = Groq(api_key=llm_config["api_key"])
|
|
13
|
+
|
|
14
|
+
db_config = Settings.get_db_config()
|
|
15
|
+
db_name = db_config["dbname"]
|
|
16
|
+
|
|
17
|
+
schema_path = Path.home() / ".natquery" / db_name / "schema.json"
|
|
18
|
+
|
|
19
|
+
if schema_path.exists():
|
|
20
|
+
with open(schema_path, "r") as f:
|
|
21
|
+
schema = json.load(f)
|
|
22
|
+
else:
|
|
23
|
+
schema = {}
|
|
24
|
+
|
|
25
|
+
schema_text = format_schema(schema)
|
|
26
|
+
|
|
27
|
+
error_type = classify_error(error_message)
|
|
28
|
+
|
|
29
|
+
# Dynamic instruction based on error type
|
|
30
|
+
correction_hint = {
|
|
31
|
+
"SYNTAX_ERROR": "Fix SQL syntax.",
|
|
32
|
+
"UNDEFINED_TABLE": "Use only valid table names from schema.",
|
|
33
|
+
"UNDEFINED_COLUMN": "Fix incorrect column names using schema.",
|
|
34
|
+
"AMBIGUOUS_COLUMN": "Disambiguate columns using table aliases.",
|
|
35
|
+
"TYPE_ERROR": "Fix type mismatches and operators.",
|
|
36
|
+
"GROUP_BY_ERROR": "Ensure proper GROUP BY usage.",
|
|
37
|
+
"JOIN_ERROR": "Fix joins using foreign key relationships.",
|
|
38
|
+
"UNKNOWN": "Fix the SQL query to make it executable.",
|
|
39
|
+
}[error_type]
|
|
40
|
+
|
|
41
|
+
system_prompt = f"""
|
|
42
|
+
You are a PostgreSQL expert specializing in fixing SQL queries.
|
|
43
|
+
|
|
44
|
+
ERROR TYPE: {error_type}
|
|
45
|
+
|
|
46
|
+
INSTRUCTION:
|
|
47
|
+
{correction_hint}
|
|
48
|
+
|
|
49
|
+
RULES:
|
|
50
|
+
- Output ONLY corrected SQL.
|
|
51
|
+
- Do NOT explain anything.
|
|
52
|
+
- Use only schema provided.
|
|
53
|
+
- Ensure query executes correctly.
|
|
54
|
+
|
|
55
|
+
DATABASE SCHEMA:
|
|
56
|
+
{schema_text}
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
user_prompt = f"""
|
|
60
|
+
User Query:
|
|
61
|
+
{user_query}
|
|
62
|
+
|
|
63
|
+
Previous SQL:
|
|
64
|
+
{failed_sql}
|
|
65
|
+
|
|
66
|
+
Error:
|
|
67
|
+
{error_message}
|
|
68
|
+
|
|
69
|
+
Return corrected SQL:
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
response = client.chat.completions.create(
|
|
73
|
+
model=llm_config["model"],
|
|
74
|
+
messages=[
|
|
75
|
+
{"role": "system", "content": system_prompt.strip()},
|
|
76
|
+
{"role": "user", "content": user_prompt.strip()},
|
|
77
|
+
],
|
|
78
|
+
temperature=0.0,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
sql = response.choices[0].message.content.strip()
|
|
82
|
+
sql = sql.replace("```sql", "").replace("```", "").strip()
|
|
83
|
+
|
|
84
|
+
return sql
|
natquery/ml/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def extract_plan_root(plan_json):
|
|
5
|
+
if isinstance(plan_json, list):
|
|
6
|
+
plan_json = plan_json[0]
|
|
7
|
+
return plan_json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def extract_summary(plan_json):
|
|
11
|
+
plan_json = extract_plan_root(plan_json)
|
|
12
|
+
|
|
13
|
+
root = plan_json.get("Plan", {})
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
"execution_time_ms": plan_json.get("Execution Time"),
|
|
17
|
+
"planning_time_ms": plan_json.get("Planning Time"),
|
|
18
|
+
"total_cost": root.get("Total Cost"),
|
|
19
|
+
"plan_rows": root.get("Plan Rows"),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_columns_from_condition(condition: str):
|
|
24
|
+
"""
|
|
25
|
+
Extract column names from filter/index conditions.
|
|
26
|
+
Example:
|
|
27
|
+
"((email = 'x') AND (status = 'active'))"
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
if not condition:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
# Simple heuristic (robust enough for now)
|
|
34
|
+
matches = re.findall(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*(=|>|<|>=|<=)", condition)
|
|
35
|
+
|
|
36
|
+
return list(set([m[0] for m in matches]))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def flatten_plan(node, nodes=None):
|
|
40
|
+
if nodes is None:
|
|
41
|
+
nodes = []
|
|
42
|
+
|
|
43
|
+
filter_cond = node.get("Filter")
|
|
44
|
+
index_cond = node.get("Index Cond")
|
|
45
|
+
|
|
46
|
+
extracted_columns = []
|
|
47
|
+
extracted_columns += extract_columns_from_condition(filter_cond)
|
|
48
|
+
extracted_columns += extract_columns_from_condition(index_cond)
|
|
49
|
+
|
|
50
|
+
node_info = {
|
|
51
|
+
"node_type": node.get("Node Type"),
|
|
52
|
+
"relation": node.get("Relation Name"),
|
|
53
|
+
"startup_cost": node.get("Startup Cost"),
|
|
54
|
+
"total_cost": node.get("Total Cost"),
|
|
55
|
+
"plan_rows": node.get("Plan Rows"),
|
|
56
|
+
"actual_rows": node.get("Actual Rows"),
|
|
57
|
+
"actual_time": node.get("Actual Total Time"),
|
|
58
|
+
"filter": filter_cond,
|
|
59
|
+
"index_cond": index_cond,
|
|
60
|
+
"columns": extracted_columns,
|
|
61
|
+
"join_type": node.get("Join Type"),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
nodes.append(node_info)
|
|
65
|
+
|
|
66
|
+
for child in node.get("Plans", []) or []:
|
|
67
|
+
flatten_plan(child, nodes)
|
|
68
|
+
|
|
69
|
+
return nodes
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def analyze_cost(plan_json):
|
|
73
|
+
plan_json = extract_plan_root(plan_json)
|
|
74
|
+
root = plan_json.get("Plan", {})
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"summary": extract_summary(plan_json),
|
|
78
|
+
"nodes": flatten_plan(root),
|
|
79
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
def suggest_indexes(plan_analysis):
|
|
2
|
+
nodes = plan_analysis.get("nodes", [])
|
|
3
|
+
suggestions = []
|
|
4
|
+
|
|
5
|
+
for node in nodes:
|
|
6
|
+
node_type = node.get("node_type")
|
|
7
|
+
table = node.get("relation")
|
|
8
|
+
columns = node.get("columns", [])
|
|
9
|
+
|
|
10
|
+
# SEQ SCAN (main case)
|
|
11
|
+
if node_type == "Seq Scan" and table:
|
|
12
|
+
|
|
13
|
+
if columns:
|
|
14
|
+
if len(columns) == 1:
|
|
15
|
+
col = columns[0]
|
|
16
|
+
|
|
17
|
+
suggestions.append(
|
|
18
|
+
{
|
|
19
|
+
"type": "index",
|
|
20
|
+
"table": table,
|
|
21
|
+
"columns": columns,
|
|
22
|
+
"sql": f"CREATE INDEX idx_{table}_{col} ON {table}({col});",
|
|
23
|
+
"reason": "Sequential scan with filter condition",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
else:
|
|
28
|
+
cols = ", ".join(columns)
|
|
29
|
+
|
|
30
|
+
suggestions.append(
|
|
31
|
+
{
|
|
32
|
+
"type": "composite_index",
|
|
33
|
+
"table": table,
|
|
34
|
+
"columns": columns,
|
|
35
|
+
"sql": f"CREATE INDEX idx_{table}_{'_'.join(columns)} ON {table}({cols});",
|
|
36
|
+
"reason": "Multiple filter conditions detected",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
else:
|
|
41
|
+
suggestions.append(
|
|
42
|
+
{
|
|
43
|
+
"type": "seq_scan_warning",
|
|
44
|
+
"table": table,
|
|
45
|
+
"reason": "Full table scan detected (no filter)",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Join optimization
|
|
50
|
+
if node.get("join_type"):
|
|
51
|
+
suggestions.append(
|
|
52
|
+
{
|
|
53
|
+
"type": "join",
|
|
54
|
+
"reason": "Ensure indexes exist on join keys",
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return suggestions
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import uuid
|
|
3
|
+
import hashlib
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NatQueryLogger:
|
|
9
|
+
|
|
10
|
+
BASE_DIR = Path.home() / ".natquery"
|
|
11
|
+
SYSTEM_LOG = BASE_DIR / "system.log"
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def _timestamp():
|
|
15
|
+
return datetime.now(timezone.utc).isoformat() + "Z"
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def generate_conv_id(cls) -> str:
|
|
19
|
+
return str(uuid.uuid4())
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def log_event(
|
|
23
|
+
cls,
|
|
24
|
+
level: str,
|
|
25
|
+
event: str,
|
|
26
|
+
db_name: str = None,
|
|
27
|
+
conv_id: str = None,
|
|
28
|
+
details: dict = None,
|
|
29
|
+
):
|
|
30
|
+
|
|
31
|
+
cls.BASE_DIR.mkdir(exist_ok=True)
|
|
32
|
+
|
|
33
|
+
log_entry = {
|
|
34
|
+
"timestamp": cls._timestamp(),
|
|
35
|
+
"level": level,
|
|
36
|
+
"event": event,
|
|
37
|
+
"conv_id": conv_id,
|
|
38
|
+
"db_name": db_name,
|
|
39
|
+
"details": details or {},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
with open(cls.SYSTEM_LOG, "a") as f:
|
|
43
|
+
f.write(json.dumps(log_entry) + "\n")
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def log_conversation(
|
|
47
|
+
cls,
|
|
48
|
+
db_name: str,
|
|
49
|
+
conv_id: str,
|
|
50
|
+
user_query: str,
|
|
51
|
+
generated_sql: str,
|
|
52
|
+
rows_returned: int,
|
|
53
|
+
execution_time_ms: float,
|
|
54
|
+
):
|
|
55
|
+
|
|
56
|
+
db_log_dir = cls.BASE_DIR / db_name / "logs"
|
|
57
|
+
db_log_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
conv_file = db_log_dir / "conversations.jsonl"
|
|
60
|
+
|
|
61
|
+
entry = {
|
|
62
|
+
"timestamp": cls._timestamp(),
|
|
63
|
+
"conv_id": conv_id,
|
|
64
|
+
"user_query": user_query,
|
|
65
|
+
"generated_sql": generated_sql,
|
|
66
|
+
"rows_returned": rows_returned,
|
|
67
|
+
"execution_time_ms": execution_time_ms,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
with open(conv_file, "a") as f:
|
|
71
|
+
f.write(json.dumps(entry) + "\n")
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _hash_query(sql: str) -> str:
|
|
75
|
+
return hashlib.sha256(sql.encode()).hexdigest()[:16]
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def log_performance(
|
|
79
|
+
cls,
|
|
80
|
+
db_name: str,
|
|
81
|
+
conv_id: str,
|
|
82
|
+
sql: str,
|
|
83
|
+
summary: dict,
|
|
84
|
+
suggestions: list,
|
|
85
|
+
):
|
|
86
|
+
"""
|
|
87
|
+
Stores structured performance history.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
perf_dir = cls.BASE_DIR / db_name / "logs"
|
|
91
|
+
perf_dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
perf_file = perf_dir / "performance.jsonl"
|
|
94
|
+
|
|
95
|
+
query_hash = cls._hash_query(sql)
|
|
96
|
+
|
|
97
|
+
entry = {
|
|
98
|
+
"timestamp": cls._timestamp(),
|
|
99
|
+
"conv_id": conv_id,
|
|
100
|
+
"query_hash": query_hash,
|
|
101
|
+
"sql": sql,
|
|
102
|
+
"execution_time_ms": summary.get("execution_time_ms"),
|
|
103
|
+
"total_cost": summary.get("total_cost"),
|
|
104
|
+
"planning_time_ms": summary.get("planning_time_ms"),
|
|
105
|
+
"suggestions": suggestions,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
with open(perf_file, "a") as f:
|
|
109
|
+
f.write(json.dumps(entry) + "\n")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
BASE_DIR = Path.home() / ".natquery"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_performance(db_name: str):
|
|
9
|
+
perf_file = BASE_DIR / db_name / "logs" / "performance.jsonl"
|
|
10
|
+
|
|
11
|
+
if not perf_file.exists():
|
|
12
|
+
return []
|
|
13
|
+
|
|
14
|
+
entries = []
|
|
15
|
+
with open(perf_file, "r") as f:
|
|
16
|
+
for line in f:
|
|
17
|
+
entries.append(json.loads(line))
|
|
18
|
+
|
|
19
|
+
return entries
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_last_run(db_name: str):
|
|
23
|
+
data = load_performance(db_name)
|
|
24
|
+
return data[-1] if data else None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_query_history(db_name: str, query_hash: str):
|
|
28
|
+
data = load_performance(db_name)
|
|
29
|
+
return [d for d in data if d["query_hash"] == query_hash]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def compare_last_two_runs(db_name: str):
|
|
33
|
+
data = load_performance(db_name)
|
|
34
|
+
|
|
35
|
+
if len(data) < 2:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
last = data[-1]
|
|
39
|
+
prev = data[-2]
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
"execution_time_diff": last["execution_time_ms"] - prev["execution_time_ms"],
|
|
43
|
+
"cost_diff": last["total_cost"] - prev["total_cost"],
|
|
44
|
+
"last": last,
|
|
45
|
+
"previous": prev,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_slowest_queries(db_name: str, top_k=5):
|
|
50
|
+
data = load_performance(db_name)
|
|
51
|
+
|
|
52
|
+
sorted_data = sorted(
|
|
53
|
+
data,
|
|
54
|
+
key=lambda x: x.get("execution_time_ms", 0),
|
|
55
|
+
reverse=True,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return sorted_data[:top_k]
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
def classify_error(error_message: str) -> str:
|
|
2
|
+
"""
|
|
3
|
+
Classify PostgreSQL errors into categories.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
msg = error_message.lower()
|
|
7
|
+
|
|
8
|
+
if "syntax error" in msg:
|
|
9
|
+
return "SYNTAX_ERROR"
|
|
10
|
+
|
|
11
|
+
if "does not exist" in msg:
|
|
12
|
+
if "column" in msg:
|
|
13
|
+
return "UNDEFINED_COLUMN"
|
|
14
|
+
if "relation" in msg or "table" in msg:
|
|
15
|
+
return "UNDEFINED_TABLE"
|
|
16
|
+
|
|
17
|
+
if "ambiguous" in msg:
|
|
18
|
+
return "AMBIGUOUS_COLUMN"
|
|
19
|
+
|
|
20
|
+
if "operator does not exist" in msg or "type" in msg:
|
|
21
|
+
return "TYPE_ERROR"
|
|
22
|
+
|
|
23
|
+
if "must appear in the group by clause" in msg:
|
|
24
|
+
return "GROUP_BY_ERROR"
|
|
25
|
+
|
|
26
|
+
if "missing from clause" in msg:
|
|
27
|
+
return "JOIN_ERROR"
|
|
28
|
+
|
|
29
|
+
return "UNKNOWN"
|