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)