| """SQL retriever: execute validated queries against the SQLite knowledge base.""" |
|
|
| import os |
| import re |
| import sqlite3 |
| import time |
| from pathlib import Path |
|
|
|
|
| _DANGEROUS_KEYWORDS = re.compile( |
| r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|ATTACH|DETACH|PRAGMA|" |
| r"LOAD_EXTENSION|UNION|VACUUM|REINDEX|SAVEPOINT|RELEASE|" |
| r"ROLLBACK|BEGIN|COMMIT|GRANT|REVOKE|EXPLAIN|WITH)\b", |
| re.IGNORECASE, |
| ) |
|
|
|
|
| def _strip_quoted(sql: str) -> str: |
| """Remove quoted strings and identifiers for safe keyword checking.""" |
| |
| result = re.sub(r"'(?:[^']|'')*'", "", sql) |
| |
| result = re.sub(r'"[^"]*"', "", result) |
| return result |
|
|
|
|
| def _strip_sql_comments(sql: str) -> str: |
| """Remove SQL comments (single-line -- and multi-line /* */) from a query. |
| |
| Respects single-quoted string literals: ``--`` and ``/* */`` inside |
| quotes are preserved. |
| """ |
| result = [] |
| i = 0 |
| n = len(sql) |
| while i < n: |
| |
| if sql[i] == "'": |
| result.append("'") |
| i += 1 |
| while i < n: |
| if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'": |
| result.append("''") |
| i += 2 |
| elif sql[i] == "'": |
| result.append("'") |
| i += 1 |
| break |
| else: |
| result.append(sql[i]) |
| i += 1 |
| |
| elif sql[i] == '/' and i + 1 < n and sql[i + 1] == '*': |
| i += 2 |
| while i < n: |
| if sql[i] == '*' and i + 1 < n and sql[i + 1] == '/': |
| i += 2 |
| break |
| i += 1 |
| |
| elif sql[i] == '-' and i + 1 < n and sql[i + 1] == '-': |
| i += 2 |
| while i < n and sql[i] != '\n': |
| i += 1 |
| else: |
| result.append(sql[i]) |
| i += 1 |
| return ''.join(result) |
|
|
|
|
| def _validate_sql(sql: str) -> bool: |
| """Validate that a SQL string is a safe SELECT query. |
| |
| Layer 1 of SQL injection protection (Layer 2 is the read-only connection). |
| Rejects non-SELECT statements, semicolons, dangerous keywords, |
| and subqueries (multiple SELECT keywords). |
| """ |
| stripped = sql.strip() |
| if not stripped: |
| return False |
| if ";" in stripped: |
| return False |
| if not stripped.upper().startswith("SELECT "): |
| return False |
| unquoted = _strip_quoted(stripped) |
| if _DANGEROUS_KEYWORDS.search(unquoted): |
| return False |
| |
| if re.search(r'\bsqlite_(master|schema|temp_master|temp_schema)\b', stripped, re.IGNORECASE): |
| return False |
| |
| if len(re.findall(r'\bSELECT\b', unquoted, re.IGNORECASE)) > 1: |
| return False |
| return True |
|
|
|
|
| def _get_db_path(cfg: dict) -> str: |
| """Resolve the SQLite database path from config.""" |
| sql_db_dir = cfg.get("paths", {}).get("sql_db", "sql_db") |
| if not os.path.isabs(sql_db_dir): |
| project_root = Path(__file__).resolve().parent.parent |
| sql_db_dir = os.path.join(str(project_root), sql_db_dir) |
| return os.path.join(sql_db_dir, "knowledge_base.db") |
|
|
|
|
| def execute_sql_query(sql_query: str, cfg: dict) -> list[dict]: |
| """Execute a validated SELECT query against the knowledge base SQLite DB. |
| |
| Args: |
| sql_query: A SQL SELECT query (generated by the LLM). |
| cfg: App config dict. |
| |
| Returns: |
| List of row dicts, or empty list on any error. |
| """ |
| |
| sql_query = sql_query.strip() |
| if sql_query.startswith("```"): |
| sql_query = sql_query.split("\n", 1)[-1].rsplit("```", 1)[0].strip() |
|
|
| |
| sql_query = _strip_sql_comments(sql_query).strip() |
|
|
| |
| |
| |
| sql_query = sql_query.rstrip(";").rstrip() |
|
|
| if not _validate_sql(sql_query): |
| return [] |
|
|
| db_path = _get_db_path(cfg) |
| if not os.path.exists(db_path): |
| return [] |
|
|
| max_rows = cfg.get("sql", {}).get("max_rows", 200) |
|
|
| try: |
| conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5) |
| try: |
| conn.row_factory = sqlite3.Row |
| |
| _start = time.monotonic() |
| def _progress_check(): |
| if time.monotonic() - _start > 10: |
| return 1 |
| return 0 |
| conn.set_progress_handler(_progress_check, 10000) |
| cursor = conn.execute(sql_query) |
| rows = cursor.fetchmany(max_rows) |
| return [dict(row) for row in rows] |
| finally: |
| conn.close() |
| except Exception: |
| return [] |
|
|
|
|
| def format_sql_results_as_context( |
| rows: list[dict], sql_query: str, source_file: str, |
| table_info: dict = None, max_rows: int = None, |
| ) -> str: |
| """Format SQL result rows as context for the verification pipeline. |
| |
| Uses [CHUNK-SQL-NNN] IDs for internal anchoring, consistent with |
| CHUNK-LOCAL and CHUNK-WEB patterns. |
| |
| Args: |
| rows: Query result rows as dicts. |
| sql_query: The SQL query that produced these rows. |
| source_file: Original source file path. |
| table_info: Schema entry for the table (includes column descriptions). |
| max_rows: The max_rows limit used in the query. When len(rows) >= max_rows, |
| a truncation notice is appended so the LLM knows data may be incomplete. |
| """ |
| if not rows: |
| return "" |
|
|
| row_label = f"Rows returned: {len(rows)}" |
| if max_rows is not None and len(rows) >= max_rows: |
| row_label += f" (truncated — more rows may exist, limit was {max_rows})" |
|
|
| parts = [ |
| "=== SQL Query Results (PRIMARY — from local dataset) ===\n", |
| f"Query: {sql_query}", |
| f"Source: {source_file}", |
| f"{row_label}\n", |
| ] |
|
|
| |
| if table_info: |
| table_desc = table_info.get("table_description", "") |
| if table_desc: |
| parts.append(f"Dataset description: {table_desc}\n") |
| col_descs = { |
| c.get("name", "unknown"): c.get("description", "") |
| for c in table_info.get("columns", []) |
| } |
| desc_lines = [f" {name}: {desc}" for name, desc in col_descs.items() if desc] |
| if desc_lines: |
| parts.append("Column descriptions:") |
| parts.extend(desc_lines) |
| parts.append("") |
|
|
| for i, row in enumerate(rows, 1): |
| fields = ", ".join( |
| f"{k} = {v if v is not None else 'N/A'}" for k, v in row.items() |
| ) |
| parts.append(f"[CHUNK-SQL-{i:03d}] {fields}") |
| return "\n".join(parts) |
|
|
|
|
| _FUZZY_STOPWORDS = frozenset({ |
| "the", "and", "for", "are", "but", "not", "you", "all", |
| "can", "had", "her", "was", "one", "our", "out", "has", |
| "what", "how", "who", "which", "when", "where", "with", |
| "from", "that", "this", "than", "then", "they", "been", |
| "south", "north", "east", "west", "new", "old", "united", |
| "democratic", "republic", "people", "state", "states", |
| "islamic", "federal", "kingdom", |
| }) |
|
|
|
|
| def make_fuzzy_query(sql: str, word_level: bool = False) -> str | None: |
| """Convert exact equality on text values to LIKE fuzzy matching. |
| |
| When ``word_level`` is False (default), transforms |
| ``column = 'value'`` into ``column LIKE '%value%'`` |
| (phrase-level matching). |
| |
| When ``word_level`` is True, transforms |
| ``column = 'South Korea'`` into |
| ``(column LIKE '%Korea%')`` |
| by extracting significant content words (dropping stopwords and |
| directional/geopolitical qualifiers). This handles cases where |
| "South Korea" is stored as "Korea, Republic of". |
| |
| Returns the modified query, or None if no substitutions were made. |
| """ |
| pattern = re.compile( |
| r"""("[^"]+"\s*|[A-Za-z_]\w*\s*)=\s*'([^']+)'""", |
| ) |
|
|
| if not word_level: |
| def _phrase_replace(m): |
| col_part = m.group(1) |
| value = m.group(2).replace("'", "''") |
| |
| value = value.replace("%", "\\%").replace("_", "\\_") |
| return f"{col_part}LIKE '%{value}%' ESCAPE '\\'" |
| new_sql, count = pattern.subn(_phrase_replace, sql) |
| if count == 0: |
| return None |
| return new_sql |
|
|
| |
| def _word_replace(m): |
| col_part = m.group(1) |
| value = m.group(2).replace("'", "''") |
| |
| value = value.replace("%", "\\%").replace("_", "\\_") |
| words = [ |
| w.replace("'", "''").replace("%", "\\%").replace("_", "\\_") |
| for w in re.split(r'\W+', m.group(2)) |
| if len(w) >= 3 and w.lower() not in _FUZZY_STOPWORDS |
| ] |
| esc = " ESCAPE '\\'" |
| if not words: |
| |
| return f"{col_part}LIKE '%{value}%'{esc}" |
| if len(words) == 1: |
| return f"{col_part}LIKE '%{words[0]}%'{esc}" |
| |
| conditions = [f"{col_part}LIKE '%{w}%'{esc}" for w in words] |
| return "(" + " AND ".join(conditions) + ")" |
|
|
| new_sql, count = pattern.subn(_word_replace, sql) |
| if count == 0: |
| return None |
| return new_sql |
|
|
|
|
| def _lookup_source_file(table_name: str, schema: dict) -> str: |
| """Look up the original source file for a SQL table name.""" |
| entry = schema.get(table_name) |
| if entry: |
| return entry.get("source_file", table_name) |
| return table_name |
|
|
|
|
| def build_schema_summary(schema: dict) -> str: |
| """Build a compact schema summary for injection into the QU prompt. |
| |
| Includes column descriptions (from codebook or LLM inference), |
| sample values, and statistics so the LLM knows what data looks like |
| for correct WHERE clauses and aggregation queries. |
| """ |
| if not schema: |
| return "" |
|
|
| lines = ["Available SQL tables:"] |
| for table_name, info in schema.items(): |
| row_count = info.get("row_count", 0) |
| source = info.get("source_file", "") |
| table_desc = info.get("table_description", "") |
|
|
| header = f"\n- {table_name} ({row_count} rows, from {source}):" |
| if table_desc: |
| header += f"\n Description: {table_desc}" |
| lines.append(header) |
|
|
| for c in info.get("columns", []): |
| col_type = c.get("type", "TEXT") |
| stats = c.get("stats", {}) |
| samples = c.get("sample", []) |
| desc = c.get("description", "") |
|
|
| |
| meta_parts = [col_type] |
| unique_count = stats.get("unique_count") |
| col_min = stats.get("min") |
| col_max = stats.get("max") |
|
|
| if col_min is not None and col_max is not None: |
| meta_parts.append(f"range {col_min}\u2013{col_max}") |
| if unique_count is not None: |
| meta_parts.append(f"{unique_count} unique") |
|
|
| meta_str = ", ".join(meta_parts) |
|
|
| |
| sample_str = "" |
| if samples: |
| quoted = ", ".join(f'"{s}"' for s in samples[:5]) |
| sample_str = f" e.g. {quoted}" |
|
|
| col_name = c.get("name", "unknown") |
| line = f" {col_name} ({meta_str}){sample_str}" |
| if desc: |
| line += f" \u2014 {desc}" |
| lines.append(line) |
| return "\n".join(lines) |
|
|