| import json |
| import logging |
| import math |
| import os |
| import tempfile |
| import threading |
| from typing import Dict, Any, Optional, List |
|
|
| import duckdb |
|
|
| from backend.core.data_catalog import get_data_catalog, describe_table |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class ResultTooLargeError(Exception): |
| """ |
| Raised when a spatial result has more geometries than can be rendered. |
| |
| Carries the counts so the caller can tell the agent exactly how far over it |
| is, which is what lets it rewrite the query rather than guess. |
| """ |
|
|
| def __init__(self, row_count: int, limit: int): |
| self.row_count = row_count |
| self.limit = limit |
| super().__init__( |
| f"Query matched {row_count:,} geometries, above the {limit:,} that can be " |
| f"rendered. Aggregate (GROUP BY a region, or average per hexagon across " |
| f"time) or filter (one season, one week, one country) and try again." |
| ) |
|
|
|
|
| class GeoEngine: |
| """ |
| In-memory DuckDB Spatial engine. |
| |
| Catalog tables are exposed as lazily-created VIEWs over their source files |
| (zero-copy, so startup stays fast and memory use stays low), while layers |
| produced by the agent are materialized as real tables so they can be |
| referenced by follow-up spatial operations. |
| """ |
|
|
| _instance = None |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| RENDER_REFUSAL_THRESHOLD = 400_000 |
|
|
| |
| GEOMETRY_COLUMNS = ('geom', 'geometry') |
|
|
| def __new__(cls): |
| if cls._instance is None: |
| cls._instance = super(GeoEngine, cls).__new__(cls) |
| cls._instance.initialized = False |
| return cls._instance |
|
|
| def __init__(self): |
| if self.initialized: |
| return |
|
|
| logger.info("Initializing GeoEngine (DuckDB)...") |
| try: |
| self.con = duckdb.connect(database=':memory:') |
| self.con.install_extension('spatial') |
| self.con.load_extension('spatial') |
| logger.info("GeoEngine initialized with Spatial extension.") |
| except Exception as e: |
| logger.error(f"Failed to initialize GeoEngine: {e}") |
| raise |
|
|
| self.layers: Dict[str, str] = {} |
| self.catalog = get_data_catalog() |
|
|
| |
| |
| |
| |
| |
| |
| self._lock = threading.RLock() |
|
|
| self.initialized = True |
|
|
| def fetch_all(self, sql: str) -> List[tuple]: |
| """Run a read query and return all rows, holding the connection lock.""" |
| with self._lock: |
| return self.con.execute(sql).fetchall() |
|
|
| def fetch_one(self, sql: str) -> Optional[tuple]: |
| """Run a read query and return the first row, holding the connection lock.""" |
| with self._lock: |
| return self.con.execute(sql).fetchone() |
|
|
| def describe_columns(self, table_name: str) -> List[tuple]: |
| """Column metadata for a table, holding the connection lock.""" |
| with self._lock: |
| return self.con.execute(f'DESCRIBE "{table_name}"').fetchall() |
|
|
| def is_table_loaded(self, table_name: str) -> bool: |
| """Check whether a table or view already exists in this connection.""" |
| with self._lock: |
| try: |
| self.con.execute(f'DESCRIBE "{table_name}"') |
| return True |
| except duckdb.Error: |
| return False |
|
|
| def ensure_table_loaded(self, table_name: str) -> bool: |
| """ |
| Ensure a catalog table is available in DuckDB, creating a view over its |
| source file if needed. Returns True if the table is queryable. |
| """ |
| if self.is_table_loaded(table_name): |
| return True |
|
|
| file_path = self.catalog.get_file_path(table_name) |
| if not file_path or not file_path.exists(): |
| logger.warning(f"Table {table_name} not found in catalog or file missing.") |
| return False |
|
|
| metadata = self.catalog.get_table_metadata(table_name) |
| if not metadata: |
| logger.warning(f"No metadata found for {table_name}") |
| return False |
|
|
| try: |
| logger.info(f"Lazy loading table: {table_name}") |
|
|
|
|
| |
| |
| if file_path.suffix == '.parquet' or metadata.get('format') in ('parquet', 'geoparquet'): |
| reader = f"read_parquet('{file_path}')" |
| else: |
| reader = f"ST_Read('{file_path}')" |
|
|
| with self._lock: |
| self.con.execute(f'CREATE OR REPLACE VIEW "{table_name}" AS SELECT * FROM {reader}') |
| return True |
| except Exception as e: |
| logger.error(f"Failed to load {table_name}: {e}") |
| return False |
|
|
| def get_table_schemas(self) -> str: |
| """Get the schema of every currently loaded table, for LLM context.""" |
| result = "Currently Loaded Tables:\n\n" |
|
|
| try: |
| with self._lock: |
| tables = self.con.execute("SHOW TABLES").fetchall() |
| except duckdb.Error as e: |
| logger.error(f"Error listing tables: {e}") |
| return result |
|
|
| for (table_name,) in tables: |
| result += self._describe_table(table_name) |
|
|
| return result |
|
|
| def get_table_schemas_for_tables(self, table_names: List[str]) -> str: |
| """ |
| Get the schema for specific tables, including the catalog's semantic |
| description so the LLM knows what each table actually contains. |
| """ |
| if not table_names: |
| return "No tables available in the current scope.\n" |
|
|
| result = "Available Tables:\n\n" |
| for table_name in table_names: |
| result += self._describe_table(table_name, include_description=True) |
| return result |
|
|
| def _describe_table(self, table_name: str, include_description: bool = False) -> str: |
| """Render one table's schema as LLM context. Returns "" if unavailable.""" |
| try: |
| with self._lock: |
| columns = self.con.execute(f'DESCRIBE "{table_name}"').fetchall() |
| row_count = self.con.execute(f'SELECT COUNT(*) FROM "{table_name}"').fetchone()[0] |
| except duckdb.Error: |
| return "" |
|
|
| out = f"### {table_name} ({row_count} rows)\n" |
|
|
| if include_description: |
| meta = self.catalog.get_table_metadata(table_name) |
| if meta: |
| desc = describe_table(meta) |
| if desc and desc != "No description": |
| out += f"**Description**: {desc}\n" |
|
|
| out += "Columns:\n" |
| for col_name, col_type, *_ in columns: |
| if col_name in self.GEOMETRY_COLUMNS: |
| out += f" - {col_name}: GEOMETRY (spatial data)\n" |
| else: |
| out += f" - {col_name}: {col_type}\n" |
| return out + "\n" |
|
|
| def register_layer(self, layer_id: str, geojson: Dict[str, Any]) -> str: |
| """ |
| Register a GeoJSON FeatureCollection as a queryable DuckDB table so the |
| agent can reference it in later spatial operations. Returns the table name. |
| """ |
| table_name = f"layer_{layer_id.replace('-', '_')}" |
|
|
| def json_serial(obj): |
| if hasattr(obj, 'isoformat'): |
| return obj.isoformat() |
| raise TypeError(f"Type {type(obj)} not serializable") |
|
|
| tmp_path = None |
| try: |
| with self._lock: |
| self.con.execute(f'DROP TABLE IF EXISTS "{table_name}"') |
|
|
| |
| |
| with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp: |
| json.dump(geojson, tmp, default=json_serial) |
| tmp_path = tmp.name |
|
|
| with self._lock: |
| self.con.execute(f'CREATE TABLE "{table_name}" AS SELECT * FROM ST_Read(\'{tmp_path}\')') |
|
|
| self.layers[layer_id] = table_name |
| logger.info(f"Registered layer {layer_id} as table {table_name}") |
| return table_name |
|
|
| except Exception as e: |
| logger.error(f"Error registering layer {layer_id}: {e}") |
| raise |
| finally: |
| if tmp_path and os.path.exists(tmp_path): |
| os.unlink(tmp_path) |
|
|
| def execute_spatial_query(self, sql: str) -> Dict[str, Any]: |
| """ |
| Execute a SELECT and return the result as a GeoJSON FeatureCollection. |
| |
| Handles both spatial results (a geometry column is present) and plain |
| aggregates (no geometry, features carry properties only). Every matching |
| row is returned; nothing is truncated. |
| """ |
| logger.info(f"Executing Spatial SQL: {sql}") |
|
|
| |
| |
| |
| with self._lock: |
| return self._execute_spatial_query_locked(sql) |
|
|
| def _execute_spatial_query_locked(self, sql: str) -> Dict[str, Any]: |
|
|
| |
| |
| self.con.execute(f"CREATE OR REPLACE TEMP TABLE query_result AS {sql}") |
|
|
| columns = self.con.execute("DESCRIBE query_result").fetchall() |
| col_names = [c[0] for c in columns] |
| geom_col = next((c for c in col_names if c in self.GEOMETRY_COLUMNS), None) |
|
|
| total_count = self.con.execute("SELECT COUNT(*) FROM query_result").fetchone()[0] |
| properties: Dict[str, Any] = {"row_count": total_count} |
|
|
| |
| |
| |
| if geom_col is not None and total_count > self.RENDER_REFUSAL_THRESHOLD: |
| raise ResultTooLargeError(total_count, self.RENDER_REFUSAL_THRESHOLD) |
|
|
| if geom_col is None: |
| |
| rows = self.con.execute("SELECT * FROM query_result").fetchall() |
|
|
| features = [ |
| { |
| "type": "Feature", |
| "geometry": None, |
| "properties": { |
| name: self._coerce_value(value) |
| for name, value in zip(col_names, row) |
| }, |
| } |
| for row in rows |
| ] |
| return {"type": "FeatureCollection", "features": features, "properties": properties} |
|
|
| other_cols = [c for c in col_names if c != geom_col] |
| |
| select_clause = ", ".join( |
| [f'ST_AsGeoJSON("{geom_col}")'] + [f'"{c}"' for c in other_cols] |
| ) |
| rows = self.con.execute(f"SELECT {select_clause} FROM query_result").fetchall() |
|
|
| features = [] |
| for row in rows: |
| features.append({ |
| "type": "Feature", |
| "geometry": json.loads(row[0]) if row[0] else None, |
| "properties": { |
| name: self._coerce_value(value) |
| for name, value in zip(other_cols, row[1:]) |
| }, |
| }) |
|
|
| return {"type": "FeatureCollection", "features": features, "properties": properties} |
|
|
| @staticmethod |
| def _coerce_value(value: Any) -> Any: |
| """Make a DuckDB value JSON-serializable (Parquet/WKB columns yield bytes).""" |
| if isinstance(value, bytes): |
| try: |
| return value.decode('utf-8') |
| except UnicodeDecodeError: |
| return str(value) |
| |
| |
| |
| |
| if isinstance(value, float) and (math.isnan(value) or math.isinf(value)): |
| return None |
| return value |
|
|
| def get_table_name(self, layer_id: str) -> Optional[str]: |
| return self.layers.get(layer_id) |
|
|
| def get_user_layers(self) -> List[str]: |
| """Return the table names of all agent- and user-registered layers.""" |
| return list(self.layers.values()) |
|
|
| def drop_layer(self, layer_id: str) -> bool: |
| """ |
| Drop a registered layer from DuckDB and internal tracking. |
| Returns True if it was found and dropped. |
| """ |
| table_name = self.layers.get(layer_id) |
| if not table_name: |
| logger.warning(f"Layer {layer_id} not found in GeoEngine") |
| return False |
|
|
| try: |
| with self._lock: |
| self.con.execute(f'DROP TABLE IF EXISTS "{table_name}"') |
| del self.layers[layer_id] |
| logger.info(f"Dropped layer {layer_id} (table: {table_name})") |
| return True |
| except duckdb.Error as e: |
| logger.error(f"Failed to drop layer {layer_id}: {e}") |
| return False |
|
|
|
|
| _geo_engine = None |
|
|
|
|
| def get_geo_engine() -> GeoEngine: |
| global _geo_engine |
| if _geo_engine is None: |
| _geo_engine = GeoEngine() |
| return _geo_engine |
|
|