File size: 9,432 Bytes
4e316d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b2cc0a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4e316d6
b2cc0a1
 
 
 
 
 
 
4e316d6
 
 
 
b2cc0a1
4e316d6
 
 
b2cc0a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4e316d6
 
 
 
 
 
 
 
 
 
 
 
b2cc0a1
 
 
 
 
4e316d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b2cc0a1
 
 
 
 
4e316d6
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
"""
Structured data query tool — CSV to SQLite with text-to-SQL.

Loads CSV files into an in-memory SQLite database and lets the agent
write SQL queries to answer questions about structured data.  This
covers the *structured knowledge* modality that enterprise agents
need alongside document retrieval (unstructured) and web search (live).

Safety: read-only mode, SELECT-only validation, row-count cap, timeout.
"""

from __future__ import annotations

import csv
import io
import sqlite3
from pathlib import Path

from src.agents.schemas import Tool, ToolParam

_MAX_ROWS = 50
_TIMEOUT_SECONDS = 5


class SQLStore:
    """In-memory SQLite database that ingests CSVs and executes read-only queries."""

    def __init__(self) -> None:
        self._conn = sqlite3.connect(":memory:", check_same_thread=False)
        self._conn.execute("PRAGMA journal_mode=WAL")
        self._tables: dict[str, str] = {}  # table_name -> CREATE TABLE DDL

    @property
    def tables(self) -> dict[str, str]:
        """Map of table name to its CREATE TABLE statement."""
        return dict(self._tables)

    @property
    def num_tables(self) -> int:
        return len(self._tables)

    def ingest_csv(self, name: str, text: str) -> str:
        """Load CSV text into a table, return the schema description."""
        # Sanitise table name
        table_name = "".join(c if c.isalnum() or c == "_" else "_" for c in name)
        # Remove .csv suffix (as underscored form) if present
        for suffix in ("_csv", ".csv"):
            if table_name.endswith(suffix):
                table_name = table_name[: -len(suffix)]
                break
        table_name = table_name.strip("_") or "data"

        reader = csv.reader(io.StringIO(text))
        headers = next(reader)
        # Clean column names
        columns = [
            "".join(c if c.isalnum() or c == "_" else "_" for c in h.strip())
            for h in headers
        ]

        # Infer types from first row
        first_row = next(reader, None)
        col_types: list[str] = []
        for i, col in enumerate(columns):
            if first_row and i < len(first_row):
                val = first_row[i].strip()
                try:
                    int(val)
                    col_types.append("INTEGER")
                except ValueError:
                    try:
                        float(val)
                        col_types.append("REAL")
                    except ValueError:
                        col_types.append("TEXT")
            else:
                col_types.append("TEXT")

        # Create table
        col_defs = ", ".join(
            "{} {}".format(c, t) for c, t in zip(columns, col_types)
        )
        ddl = "CREATE TABLE {} ({})".format(table_name, col_defs)
        self._conn.execute("DROP TABLE IF EXISTS {}".format(table_name))
        self._conn.execute(ddl)
        self._tables[table_name] = ddl

        # Insert rows (including the first row we already read)
        placeholders = ", ".join("?" for _ in columns)
        insert_sql = "INSERT INTO {} VALUES ({})".format(table_name, placeholders)

        rows: list[tuple] = []
        if first_row:
            rows.append(tuple(first_row))
        for row in reader:
            if row:
                rows.append(tuple(row))
        if rows:
            self._conn.executemany(insert_sql, rows)
        self._conn.commit()

        return "Table '{}': {} rows, {} columns ({})".format(
            table_name, len(rows), len(columns), ", ".join(columns),
        )

    def execute_query(self, sql: str) -> str:
        """Execute a read-only SQL query and return formatted results."""
        # Safety: only allow SELECT and PRAGMA (for schema discovery)
        stripped = sql.strip().upper()
        if not (stripped.startswith("SELECT") or stripped.startswith("PRAGMA")):
            return "Error: only SELECT and PRAGMA queries are allowed."

        try:
            cursor = self._conn.execute(sql)
            col_names = [desc[0] for desc in cursor.description]
            rows = cursor.fetchmany(_MAX_ROWS)

            if not rows:
                return "Query returned 0 rows."

            # Format as a readable table
            lines = [" | ".join(col_names)]
            lines.append("-" * len(lines[0]))
            for row in rows:
                lines.append(" | ".join(str(v) for v in row))

            result = "\n".join(lines)
            total = cursor.fetchone()
            if total is not None:
                result += "\n... (showing first {} rows)".format(_MAX_ROWS)
            return result

        except sqlite3.Error as e:
            return "SQL error: {}".format(e)

    @staticmethod
    def _column_types(ddl: str) -> list[tuple[str, str]]:
        """Parse (name, type) pairs from a CREATE TABLE DDL."""
        inner = ddl.split("(", 1)[1].rsplit(")", 1)[0] if "(" in ddl else ""
        out: list[tuple[str, str]] = []
        for c in inner.split(","):
            parts = c.strip().split()
            if len(parts) >= 2:
                out.append((parts[0], parts[1]))
        return out

    def _distinct_values(self, table: str, col: str, limit: int = 40) -> list[str]:
        """Distinct values of a column (up to limit+1, to detect overflow)."""
        rows = self._conn.execute(
            "SELECT DISTINCT {} FROM {} LIMIT {}".format(col, table, limit + 1)
        ).fetchall()
        return [str(r[0]) for r in rows if r[0] is not None]

    def get_schema_description(self) -> str:
        """Describe all tables for the system prompt.

        Beyond column names, this lists the distinct values of low-cardinality
        TEXT columns and an example query.  Small models otherwise treat a
        long/tidy table's category values (e.g. a ``metric`` of ``total_revenue``)
        as column names and produce "no such column" errors.
        """
        if not self._tables:
            return "No data tables loaded."
        parts: list[str] = []
        for table_name, ddl in self._tables.items():
            coltypes = self._column_types(ddl)
            row_count = self._conn.execute(
                "SELECT COUNT(*) FROM {}".format(table_name)
            ).fetchone()[0]
            col_str = ", ".join("{} {}".format(c, t) for c, t in coltypes)
            parts.append("{} ({} rows): {}".format(table_name, row_count, col_str))

            numeric_cols = [c for c, t in coltypes if t in ("INTEGER", "REAL")]
            example_filters: list[tuple[str, str]] = []
            cat_lines: list[str] = []
            for c, t in coltypes:
                if t != "TEXT":
                    continue
                vals = self._distinct_values(table_name, c, limit=40)
                if 1 <= len(vals) <= 40:
                    cat_lines.append("    {}: {}".format(c, ", ".join(vals)))
                    if len(example_filters) < 2:
                        example_filters.append((c, vals[0]))
            if cat_lines:
                parts.append("  filter these columns by exact value:")
                parts.extend(cat_lines)
            if numeric_cols and example_filters:
                where = " AND ".join(
                    "{}='{}'".format(c, v) for c, v in example_filters
                )
                parts.append(
                    "  example: SELECT {} FROM {} WHERE {}".format(
                        numeric_cols[0], table_name, where,
                    )
                )
        return "\n".join(parts)

    def close(self) -> None:
        self._conn.close()


def make_sql_tool(store: SQLStore) -> Tool:
    """Create a SQL query tool bound to the given SQLStore."""

    def _get_description() -> str:
        schema = store.get_schema_description()
        return (
            "Execute a SQL SELECT query against uploaded CSV data. "
            "Always run a query to get the answer — do not guess from the schema. "
            "Tables may be in long format: filter the listed columns by their exact "
            "values (see 'filter these columns by exact value') and SELECT the numeric "
            "column — do not use a category value as a column name.\n"
            "Tables:\n{}".format(schema)
        )

    def execute(query: str) -> str:
        return store.execute_query(query)

    tool = Tool(
        name="query_data",
        description=_get_description(),
        params=[
            ToolParam(
                "query", "str",
                "A SQL SELECT query to execute, e.g. 'SELECT AVG(salary) FROM employees'.",
            ),
        ],
        execute=execute,
    )
    # Attach store reference so the description can be refreshed after new CSV ingestion
    tool._sql_store = store  # type: ignore[attr-defined]
    return tool


def refresh_sql_tool_description(tool: Tool) -> None:
    """Update the tool description after new CSVs are ingested."""
    store: SQLStore = tool._sql_store  # type: ignore[attr-defined]
    tool.description = (
        "Execute a SQL SELECT query against uploaded CSV data. "
        "Always run a query to get the answer — do not guess from the schema. "
        "Tables may be in long format: filter the listed columns by their exact "
        "values (see 'filter these columns by exact value') and SELECT the numeric "
        "column — do not use a category value as a column name.\n"
        "Tables:\n{}".format(store.get_schema_description())
    )