Spaces:
Sleeping
Sleeping
| """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 | |
| 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} | |
| 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 | |
| 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. | |
| """ | |
| 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 "" | |
| 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) | |