SWMM_MCP_Server_Claude / sql_agent.py
razaali10's picture
Upload 20 files
e545bf5 verified
Raw
History Blame Contribute Delete
17.2 kB
"""Safe provider-agnostic SQL retrieval agent for the complete SWMM SQLite store.
The language model produces a constrained JSON retrieval plan. This module validates
that plan and executes read-only SELECT statements only. No model-generated SQL is
executed directly.
"""
from __future__ import annotations
import json
import math
import re
import sqlite3
from dataclasses import dataclass
from typing import Any
import pandas as pd
_ALLOWED_FILTER_OPS = {
"eq": "=", "ne": "!=", "gt": ">", "gte": ">=", "lt": "<", "lte": "<=",
"like": "LIKE", "in": "IN", "between": "BETWEEN",
"is_null": "IS NULL", "not_null": "IS NOT NULL",
}
_ALLOWED_AGGS = {"MAX", "MIN", "AVG", "SUM", "COUNT"}
_MAX_ACTIONS = 6
_MAX_ROWS_PER_ACTION = 120
_MAX_CONTEXT_CHARS = 45_000
@dataclass
class RetrievalResult:
context: str
audit: list[dict[str, Any]]
plan: dict[str, Any]
class SafeSQLAgent:
"""Validate and execute bounded, read-only retrieval plans against a ResultDatabase."""
def __init__(self, result_db: Any) -> None:
self.db = result_db
self.conn: sqlite3.Connection = result_db.connection
self._tables = self._read_tables()
self._columns = {table: self._read_columns(table) for table in self._tables}
@staticmethod
def _quote_identifier(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def _read_tables(self) -> list[str]:
rows = self.conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
).fetchall()
return [str(r[0]) for r in rows]
def _read_columns(self, table: str) -> list[str]:
if table not in self._tables:
return []
rows = self.conn.execute(f"PRAGMA table_info({self._quote_identifier(table)})").fetchall()
return [str(r[1]) for r in rows]
def _row_count(self, table: str) -> int:
try:
return int(self.conn.execute(
f"SELECT COUNT(*) FROM {self._quote_identifier(table)}"
).fetchone()[0])
except Exception:
return 0
@staticmethod
def _tokens(text: str) -> set[str]:
return {t.lower() for t in re.findall(r"[A-Za-z0-9_:.\-]+", text) if len(t) > 1}
def schema_context(self, question: str, max_tables: int = 16) -> str:
"""Return a compact, question-ranked schema catalogue for the planning call."""
q_tokens = self._tokens(question)
synonyms = {
"node": {"node", "junction", "outfall", "storage", "manhole", "flood", "head", "depth"},
"link": {"link", "conduit", "pipe", "pump", "weir", "orifice", "outlet", "flow", "velocity", "capacity"},
"subcatchment": {"subcatchment", "catchment", "runoff", "rainfall", "infiltration", "hydrology"},
"input": {"input", "option", "roughness", "diameter", "length", "invert", "curve", "pattern", "control"},
"timeseries": {"time", "timeseries", "duration", "when", "peak", "first", "last", "hour"},
}
expanded = set(q_tokens)
for _, words in synonyms.items():
if q_tokens & words:
expanded |= words
always = {
"simulation_metadata", "simulation_warnings", "node_summary", "link_summary",
"subcatchment_summary", "model_input_section_catalog",
}
scored: list[tuple[float, str]] = []
for table in self._tables:
hay = self._tokens(table + " " + " ".join(self._columns[table]))
score = float(len(expanded & hay) * 5)
for token in expanded:
if token in table.lower():
score += 3
score += sum(1 for c in self._columns[table] if token in c.lower()) * 0.5
if table in always:
score += 4
if table.endswith("_timeseries") and (expanded & synonyms["timeseries"]):
score += 5
if table.startswith("inp_") and (expanded & synonyms["input"]):
score += 5
scored.append((score, table))
selected = [t for _, t in sorted(scored, reverse=True)[:max_tables]]
for table in always:
if table in self._tables and table not in selected:
selected.append(table)
selected = selected[:max_tables]
lines = ["AVAILABLE SQLITE TABLES (question-ranked):"]
for table in selected:
cols = self._columns[table]
count = self._row_count(table)
lines.append(f"- {table} ({count} rows): {', '.join(cols)}")
lines.append(
"The complete database may contain additional inp_<section> tables. "
"Use model_input_lines to search exact source text when a section is not listed."
)
return "\n".join(lines)
def planner_system_prompt(self, schema_context: str) -> str:
return f"""You are a retrieval planner for an EPA SWMM SQLite database.
Return ONLY valid JSON. Do not answer the engineering question.
Create the smallest read-only retrieval plan that supplies enough evidence for a later engineering answer.
Never emit SQL. Use only the operations and fields below.
{schema_context}
JSON format:
{{
"reasoning_summary": "brief retrieval rationale",
"actions": [
{{
"operation": "select",
"table": "table_name",
"columns": ["column1", "column2"],
"filters": [{{"column":"column1","op":"eq|ne|gt|gte|lt|lte|like|in|between|is_null|not_null","value": "value or list"}}],
"order_by": [{{"column":"column1","direction":"asc|desc"}}],
"limit": 40,
"label": "descriptive result label"
}},
{{
"operation": "aggregate",
"table": "table_name",
"group_by": ["optional_column"],
"metrics": [{{"function":"MAX|MIN|AVG|SUM|COUNT","column":"column_or_*","alias":"metric_name"}}],
"filters": [],
"order_by": [{{"column":"metric_alias_or_group_column","direction":"desc"}}],
"limit": 40,
"label": "descriptive result label"
}},
{{
"operation": "search_input",
"term": "text to find in raw SWMM input lines",
"section": "optional SWMM section name without brackets",
"limit": 40,
"label": "descriptive result label"
}},
{{
"operation": "describe_table",
"table": "table_name",
"label": "table structure"
}}
]
}}
Rules:
- Maximum 6 actions and normally 1-4 actions.
- Use exact asset IDs appearing in the user's question as equality filters.
- Prefer summary/aggregate retrieval for broad questions and detailed time-series only for named assets or explicit temporal questions.
- For first/last/peak questions, use ordering and a small limit or an aggregate.
- Retrieve model inputs from inp_* tables or model_input_lines.
- Never request full tables. Keep each limit at or below 120.
- Use engineering evidence from both input and output tables when the question asks for diagnosis or recommendations.
"""
@staticmethod
def parse_plan(text: str) -> dict[str, Any]:
cleaned = text.strip()
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r"\s*```$", "", cleaned)
start, end = cleaned.find("{"), cleaned.rfind("}")
if start >= 0 and end > start:
cleaned = cleaned[start:end + 1]
plan = json.loads(cleaned)
if not isinstance(plan, dict):
raise ValueError("Planner response must be a JSON object")
actions = plan.get("actions", [])
if not isinstance(actions, list):
raise ValueError("Planner actions must be a list")
plan["actions"] = actions[:_MAX_ACTIONS]
return plan
def _validate_table(self, table: str) -> str:
if table not in self._tables:
raise ValueError(f"Unknown table: {table}")
return table
def _validate_column(self, table: str, column: str, *, allow_star: bool = False) -> str:
if allow_star and column == "*":
return column
if column not in self._columns[table]:
raise ValueError(f"Unknown column {column!r} in {table}")
return column
def _build_filters(self, table: str, filters: Any) -> tuple[str, list[Any]]:
if not filters:
return "", []
clauses: list[str] = []
params: list[Any] = []
for item in list(filters)[:12]:
if not isinstance(item, dict):
continue
col = self._validate_column(table, str(item.get("column", "")))
op_key = str(item.get("op", "eq")).lower()
if op_key not in _ALLOWED_FILTER_OPS:
raise ValueError(f"Unsupported filter operation: {op_key}")
sql_op = _ALLOWED_FILTER_OPS[op_key]
qcol = self._quote_identifier(col)
value = item.get("value")
if op_key in {"is_null", "not_null"}:
clauses.append(f"{qcol} {sql_op}")
elif op_key == "in":
values = value if isinstance(value, list) else [value]
values = values[:50]
if not values:
clauses.append("1=0")
else:
clauses.append(f"{qcol} IN ({','.join('?' for _ in values)})")
params.extend(values)
elif op_key == "between":
values = value if isinstance(value, list) else []
if len(values) != 2:
raise ValueError("between requires exactly two values")
clauses.append(f"{qcol} BETWEEN ? AND ?")
params.extend(values)
else:
clauses.append(f"{qcol} {sql_op} ?")
params.append(value)
return (" WHERE " + " AND ".join(clauses)) if clauses else "", params
def _build_order(self, table: str, order_by: Any, aliases: set[str] | None = None) -> str:
if not order_by:
return ""
aliases = aliases or set()
parts: list[str] = []
for item in list(order_by)[:4]:
if not isinstance(item, dict):
continue
col = str(item.get("column", ""))
if col not in aliases:
self._validate_column(table, col)
direction = "DESC" if str(item.get("direction", "asc")).lower() == "desc" else "ASC"
parts.append(f"{self._quote_identifier(col)} {direction}")
return " ORDER BY " + ", ".join(parts) if parts else ""
@staticmethod
def _clean_frame(df: pd.DataFrame) -> pd.DataFrame:
out = df.copy()
for col in out.columns:
out[col] = out[col].map(
lambda v: None if isinstance(v, float) and (math.isnan(v) or math.isinf(v)) else v
)
return out
def _execute_select(self, action: dict[str, Any]) -> tuple[pd.DataFrame, str]:
table = self._validate_table(str(action.get("table", "")))
requested = action.get("columns") or self._columns[table]
columns = [self._validate_column(table, str(c)) for c in requested]
columns = columns[:24]
where_sql, params = self._build_filters(table, action.get("filters"))
order_sql = self._build_order(table, action.get("order_by"))
limit = min(max(int(action.get("limit", 40)), 1), _MAX_ROWS_PER_ACTION)
sql = (
"SELECT " + ", ".join(self._quote_identifier(c) for c in columns) +
f" FROM {self._quote_identifier(table)}" + where_sql + order_sql + " LIMIT ?"
)
df = pd.read_sql_query(sql, self.conn, params=(*params, limit))
return self._clean_frame(df), sql
def _execute_aggregate(self, action: dict[str, Any]) -> tuple[pd.DataFrame, str]:
table = self._validate_table(str(action.get("table", "")))
group_by = [self._validate_column(table, str(c)) for c in (action.get("group_by") or [])][:6]
metric_exprs: list[str] = []
aliases: set[str] = set()
for metric in (action.get("metrics") or [])[:12]:
if not isinstance(metric, dict):
continue
fn = str(metric.get("function", "")).upper()
if fn not in _ALLOWED_AGGS:
raise ValueError(f"Unsupported aggregate: {fn}")
col = str(metric.get("column", "*"))
self._validate_column(table, col, allow_star=(fn == "COUNT"))
alias = re.sub(r"[^A-Za-z0-9_]+", "_", str(metric.get("alias") or f"{fn.lower()}_{col}"))
aliases.add(alias)
expr_col = "*" if col == "*" else self._quote_identifier(col)
metric_exprs.append(f"{fn}({expr_col}) AS {self._quote_identifier(alias)}")
if not metric_exprs:
metric_exprs = ['COUNT(*) AS "row_count"']
aliases.add("row_count")
select_parts = [self._quote_identifier(c) for c in group_by] + metric_exprs
where_sql, params = self._build_filters(table, action.get("filters"))
group_sql = " GROUP BY " + ", ".join(self._quote_identifier(c) for c in group_by) if group_by else ""
order_sql = self._build_order(table, action.get("order_by"), aliases=aliases)
limit = min(max(int(action.get("limit", 40)), 1), _MAX_ROWS_PER_ACTION)
sql = (
"SELECT " + ", ".join(select_parts) + f" FROM {self._quote_identifier(table)}" +
where_sql + group_sql + order_sql + " LIMIT ?"
)
df = pd.read_sql_query(sql, self.conn, params=(*params, limit))
return self._clean_frame(df), sql
def _execute_search_input(self, action: dict[str, Any]) -> tuple[pd.DataFrame, str]:
term = str(action.get("term", "")).strip()
if not term:
raise ValueError("search_input requires a term")
section = str(action.get("section", "")).strip().upper()
limit = min(max(int(action.get("limit", 40)), 1), _MAX_ROWS_PER_ACTION)
sql = (
"SELECT line_no, section_name, section_row_no, raw_text FROM model_input_lines "
"WHERE lower(raw_text) LIKE lower(?)"
)
params: list[Any] = [f"%{term}%"]
if section:
sql += " AND upper(section_name)=?"
params.append(section)
sql += " ORDER BY line_no LIMIT ?"
params.append(limit)
df = pd.read_sql_query(sql, self.conn, params=params)
return self._clean_frame(df), sql
def _execute_describe(self, action: dict[str, Any]) -> tuple[pd.DataFrame, str]:
table = self._validate_table(str(action.get("table", "")))
rows = self.conn.execute(f"PRAGMA table_info({self._quote_identifier(table)})").fetchall()
df = pd.DataFrame(rows, columns=["cid", "name", "type", "notnull", "default_value", "primary_key"])
df["row_count"] = self._row_count(table)
return df, f"PRAGMA table_info({table})"
def execute_plan(self, plan: dict[str, Any]) -> RetrievalResult:
contexts: list[str] = []
audit: list[dict[str, Any]] = []
used_chars = 0
for index, action in enumerate(plan.get("actions", [])[:_MAX_ACTIONS], start=1):
if not isinstance(action, dict):
continue
operation = str(action.get("operation", "select")).lower()
label = str(action.get("label") or f"Retrieval {index}")
try:
if operation == "select":
df, sql = self._execute_select(action)
elif operation == "aggregate":
df, sql = self._execute_aggregate(action)
elif operation == "search_input":
df, sql = self._execute_search_input(action)
elif operation == "describe_table":
df, sql = self._execute_describe(action)
else:
raise ValueError(f"Unsupported operation: {operation}")
csv_text = df.to_csv(index=False)
block = f"=== {label} ===\n{csv_text}"
remaining = _MAX_CONTEXT_CHARS - used_chars
if remaining <= 0:
break
if len(block) > remaining:
block = block[:remaining] + "\n[retrieval context truncated]"
contexts.append(block)
used_chars += len(block)
audit.append({
"action": index,
"label": label,
"operation": operation,
"table": action.get("table", "model_input_lines" if operation == "search_input" else ""),
"rows_returned": int(len(df)),
"status": "ok",
})
except Exception as exc:
audit.append({
"action": index,
"label": label,
"operation": operation,
"table": action.get("table", ""),
"rows_returned": 0,
"status": f"error: {exc}",
})
return RetrievalResult(context="\n\n".join(contexts), audit=audit, plan=plan)