"""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_
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)