Spaces:
Running
Running
File size: 12,024 Bytes
54bc2bb 106354e 54bc2bb f95cdb7 54bc2bb 106354e 9093d8f f95cdb7 9093d8f 106354e fbd18c8 9093d8f f9f7706 9093d8f 54bc2bb 106354e 9093d8f 54bc2bb fbd18c8 54bc2bb fbd18c8 106354e 9093d8f fbd18c8 9093d8f 54bc2bb 1330e5c 9093d8f f97b9fe 54bc2bb f95cdb7 39c2d11 f95cdb7 39c2d11 54bc2bb 203c044 fbd18c8 203c044 3d1d5b4 1330e5c 203c044 1330e5c fbd18c8 1330e5c 203c044 1330e5c 203c044 3d1d5b4 a8fd301 3d1d5b4 203c044 d6a01c7 203c044 d4a1a18 6b689a5 d4a1a18 6b689a5 f95cdb7 6b689a5 d4a1a18 fbd18c8 d4a1a18 fbd18c8 d4a1a18 fbd18c8 d4a1a18 fbd18c8 d4a1a18 fbd18c8 d4a1a18 fbd18c8 d4a1a18 fbd18c8 d4a1a18 6b689a5 203c044 3d1d5b4 203c044 353f65a 3d1d5b4 86e20a0 3d1d5b4 86e20a0 3d1d5b4 353f65a 3d1d5b4 353f65a 3d1d5b4 353f65a 3d1d5b4 353f65a 3d1d5b4 d6a01c7 3d1d5b4 203c044 | 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 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 | """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."""
# Remove single-quoted strings (handles '' escaping)
result = re.sub(r"'(?:[^']|'')*'", "", sql)
# Remove double-quoted identifiers
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:
# Single-quoted string literal — copy verbatim (handles '' escaping)
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
# Block comment
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
# Line comment
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
# Block access to SQLite system tables
if re.search(r'\bsqlite_(master|schema|temp_master|temp_schema)\b', stripped, re.IGNORECASE):
return False
# Block subqueries: reject if more than one SELECT keyword
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.
"""
# Strip markdown code fences if present (LLMs often wrap SQL in ```sql...```)
sql_query = sql_query.strip()
if sql_query.startswith("```"):
sql_query = sql_query.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
# Strip comments before validation AND execution
sql_query = _strip_sql_comments(sql_query).strip()
# Strip trailing semicolons — LLMs add them ~90% of the time.
# Must happen AFTER comment removal but BEFORE validation, so that
# _validate_sql() still rejects internal semicolons (statement chaining).
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
# Abort long-running queries after 10 seconds
_start = time.monotonic()
def _progress_check():
if time.monotonic() - _start > 10:
return 1 # non-zero = abort
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",
]
# Include table and column descriptions so the LLM understands the data
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("'", "''")
# Escape LIKE wildcards in the value
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
# Word-level: extract significant words from each value
def _word_replace(m):
col_part = m.group(1)
value = m.group(2).replace("'", "''")
# Escape LIKE wildcards in the value
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:
# Fallback: use the original value as phrase LIKE
return f"{col_part}LIKE '%{value}%'{esc}"
if len(words) == 1:
return f"{col_part}LIKE '%{words[0]}%'{esc}"
# Multiple words: join with AND on the same column
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", "")
# Build metadata string
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)
# Build sample string
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)
|