File size: 13,896 Bytes
a7149c5
 
 
9b9dfa3
a7149c5
 
 
 
 
 
 
 
 
 
 
fe50a27
a7149c5
ea7e05e
6284cc6
 
ea7e05e
84ff571
eddc0a5
a7149c5
35c1901
 
a7149c5
3f18386
6284cc6
 
 
 
a7149c5
 
 
6284cc6
 
a7149c5
 
 
 
6284cc6
a7149c5
 
 
 
 
 
 
6284cc6
a7149c5
 
 
 
 
 
edfc852
 
 
 
 
 
 
 
a7149c5
 
 
35c1901
 
 
 
 
 
 
 
 
 
 
a7149c5
35c1901
a7149c5
 
 
6284cc6
a7149c5
 
35c1901
6284cc6
 
a7149c5
 
 
 
 
6284cc6
a7149c5
 
 
35c1901
a7149c5
35c1901
 
6284cc6
3f18386
 
6284cc6
35c1901
 
6284cc6
a7149c5
 
6284cc6
a7149c5
 
 
 
6284cc6
 
 
 
 
 
ea7e05e
 
 
f8c0f14
ea7e05e
 
 
 
6284cc6
ea7e05e
 
 
 
 
 
 
6284cc6
 
 
ea7e05e
6284cc6
 
ea7e05e
 
6284cc6
ea7e05e
 
a7149c5
 
55283c2
6284cc6
55283c2
 
 
 
6284cc6
55283c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6284cc6
55283c2
 
 
 
 
 
 
6284cc6
 
55283c2
 
6284cc6
55283c2
 
 
 
a7149c5
 
 
 
 
 
 
 
 
 
6284cc6
6c3c3ed
 
 
 
6284cc6
a7149c5
 
6284cc6
 
 
 
 
 
a7149c5
 
 
 
 
6284cc6
a7149c5
6c3c3ed
a7149c5
6c3c3ed
a7149c5
 
 
6284cc6
a7149c5
 
6284cc6
a7149c5
 
 
e8e14dc
6284cc6
a7149c5
 
eddc0a5
 
a7149c5
6284cc6
a7149c5
6284cc6
a7149c5
 
 
35c1901
3f18386
6284cc6
 
a7149c5
6284cc6
a7149c5
 
 
6284cc6
a7149c5
6c3c3ed
6284cc6
 
 
 
ea7e05e
6284cc6
ea7e05e
f8c0f14
 
 
 
 
 
6284cc6
f8c0f14
 
6284cc6
 
a7149c5
f8c0f14
 
 
 
 
6b5ae40
f8c0f14
 
 
 
 
6284cc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""FastAPI router — /health, /sql/schema, /sql/upload, /sql/connect, /sql/query."""
from __future__ import annotations

import asyncio
import os
import shutil
import uuid
from pathlib import Path
from typing import AsyncGenerator

from fastapi import APIRouter, File, Form, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel

from ._explain import (
    _sse, build_explain_prompt, stream_explanation,
)
from ._execute import (
    UnsafeQueryError, validate_sql, result_to_dict, mask_sensitive_columns,
    paginate_sql, paginate_mssql_sql, count_rows_sqlite, count_rows_remote,
)
from ._generate import generate_sql, generate_filter_expr, get_provider_cfg, sanitize_question, generate_sample_questions, generate_followup_suggestions
from ._providers import RateLimitError
from ._schema import (
    DBSchema, load_pg_schema, load_sqlite_schema, load_mysql_schema, load_duckdb_schema,
    schema_to_dict, schema_to_prompt_text,
)
from ._schema_mssql import load_mssql_schema
from ._session_mgr import (
    CHINOOK_PATH, _UPLOAD_DIR, _sessions,
    check_rate_limit, save_session_index, exec_session, preload_chinook,
)

router = APIRouter()

_SQLITE_EXTS = {".db", ".sqlite", ".sqlite3"}
_DUCKDB_EXTS = {".duckdb", ".parquet", ".csv"}


@router.get("/health")
async def health():
    await preload_chinook()
    chinook = _sessions.get("chinook")
    tables  = len(chinook["schema"].tables) if chinook else 0
    return {"status": "ok", "demo_db": "chinook.db", "tables": tables}


@router.get("/sql/schema")
async def get_schema(db_ref: str = "chinook"):
    await preload_chinook()
    session = _sessions.get(db_ref)
    if not session:
        return JSONResponse({"error": f"Unknown db_ref: {db_ref}"}, status_code=404)
    return schema_to_dict(session["schema"])


@router.get("/sql/sample-questions")
async def get_sample_questions(db_ref: str, provider: str = "groq"):
    session = _sessions.get(db_ref)
    if not session:
        return JSONResponse({"error": "session not found"}, status_code=404)
    key = os.environ.get(get_provider_cfg(provider)["env"], "")
    return {"questions": await generate_sample_questions(session["schema"], provider, key)}


@router.post("/sql/upload")
async def upload_db(file: UploadFile = File(...)):
    fname = file.filename or ""
    ext   = Path(fname).suffix.lower()
    if ext in _SQLITE_EXTS:
        stype = "sqlite"
    elif ext in _DUCKDB_EXTS:
        stype = "duckdb"
    else:
        return JSONResponse(
            {"error": "Upload a .db / .sqlite / .sqlite3 / .duckdb / .parquet / .csv file"},
            status_code=400,
        )
    db_ref  = f"upload_{uuid.uuid4().hex[:8]}"
    db_path = _UPLOAD_DIR / f"{db_ref}{ext}"
    with db_path.open("wb") as f:
        shutil.copyfileobj(file.file, f)
    try:
        schema = await load_duckdb_schema(str(db_path)) if stype == "duckdb" else await load_sqlite_schema(str(db_path))
    except Exception as e:
        db_path.unlink(missing_ok=True)
        return JSONResponse({"error": f"Could not read file: {e}"}, status_code=422)
    _sessions[db_ref] = {"type": stype, "path": str(db_path), "schema": schema, "created_at": __import__("time").time()}
    save_session_index()
    return {"db_ref": db_ref, "schema": schema_to_dict(schema)}


class ConnectRequest(BaseModel):
    conn_str: str
    db_type: str = "postgresql"


@router.post("/sql/connect")
async def connect_db(req: ConnectRequest):
    try:
        if req.db_type == "mysql":
            schema = await load_mysql_schema(req.conn_str)
            db_ref, stype = f"mysql_{uuid.uuid4().hex[:8]}", "mysql"
        elif req.db_type == "mssql":
            schema = await load_mssql_schema(req.conn_str)
            db_ref, stype = f"mssql_{uuid.uuid4().hex[:8]}", "mssql"
        else:
            schema = await load_pg_schema(req.conn_str)
            db_ref, stype = f"pg_{uuid.uuid4().hex[:8]}", "postgresql"
    except Exception as e:
        return JSONResponse({"error": f"Connection failed: {e}"}, status_code=422)
    _sessions[db_ref] = {"type": stype, "conn_str": req.conn_str, "schema": schema, "created_at": __import__("time").time()}
    return {"db_ref": db_ref, "schema": schema_to_dict(schema)}


class QueryRequest(BaseModel):
    question:   str
    provider:   str = "groq"
    db_ref:     str = "chinook"
    history:    list[dict] = []
    glossary:   str = ""
    correction: str = ""


class PageRequest(BaseModel):
    sql: str; db_ref: str = "chinook"; page: int = 1; page_size: int = 50


@router.post("/sql/page")
async def page_results(req: PageRequest):
    await preload_chinook()
    session = _sessions.get(req.db_ref)
    if not session:
        return JSONResponse({"error": "Session expired or unknown"}, status_code=404)
    try:
        validate_sql(req.sql)
    except UnsafeQueryError as e:
        return JSONResponse({"error": str(e)}, status_code=400)
    page, page_size = max(1, req.page), min(max(10, req.page_size), 200)
    stype  = session["type"]
    paged  = paginate_mssql_sql(req.sql, page, page_size) if stype == "mssql" else paginate_sql(req.sql, page, page_size)
    try:
        result = await exec_session(session, paged)
        total  = await count_rows_sqlite(session["path"], req.sql) if stype == "sqlite" else await count_rows_remote(session, req.sql) if stype in ("postgresql", "mysql", "mssql") else -1
    except Exception as e:
        return JSONResponse({"error": str(e)}, status_code=500)
    d = result_to_dict(mask_sensitive_columns(result))
    d.update({"total_count": total, "page": page, "page_size": page_size})
    return d


class FilterRequest(BaseModel):
    sql: str; db_ref: str = "chinook"; filter_text: str; columns: list[str] = []; provider: str = "groq"


@router.post("/sql/filter")
async def filter_results(req: FilterRequest):
    await preload_chinook()
    session = _sessions.get(req.db_ref)
    if not session:
        return JSONResponse({"error": "Session expired or unknown"}, status_code=404)
    try:
        validate_sql(req.sql)
    except UnsafeQueryError as e:
        return JSONResponse({"error": str(e)}, status_code=400)
    cfg = get_provider_cfg(req.provider)
    key = os.environ.get(cfg["env"], "")
    if not key:
        return JSONResponse({"error": f"{cfg['env']} not configured"}, status_code=422)
    try:
        expr = await generate_filter_expr(sanitize_question(req.filter_text), req.columns, req.provider, key)
    except Exception as e:
        return JSONResponse({"error": f"Filter generation failed: {e}"}, status_code=500)
    filtered_sql = f"SELECT * FROM ({req.sql.rstrip(';').strip()}) AS _filtered WHERE {expr}"
    try:
        validate_sql(filtered_sql)
    except UnsafeQueryError as e:
        return JSONResponse({"error": f"Generated filter is unsafe: {e}"}, status_code=400)
    stype = session["type"]
    paged = paginate_mssql_sql(filtered_sql, 1, 50) if stype == "mssql" else paginate_sql(filtered_sql, 1, 50)
    try:
        result = await exec_session(session, paged)
        total  = await count_rows_sqlite(session["path"], filtered_sql) if stype == "sqlite" else await count_rows_remote(session, filtered_sql) if stype in ("postgresql", "mysql", "mssql") else -1
    except Exception as e:
        return JSONResponse({"error": str(e)}, status_code=500)
    d = result_to_dict(mask_sensitive_columns(result))
    d.update({"filtered_sql": filtered_sql, "filter_expr": expr, "total_count": total, "page": 1, "page_size": 50})
    return d


@router.post("/sql/query")
async def query_sql(req: QueryRequest):
    return StreamingResponse(
        _run_pipeline(req),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )


async def _run_pipeline(req: QueryRequest) -> AsyncGenerator[str, None]:
    if not check_rate_limit():
        yield _sse({"type": "error", "text": "Rate limit reached. Please wait a moment and try again."})
        yield _sse({"type": "done"})
        return

    await preload_chinook()
    session = _sessions.get(req.db_ref)
    if not session:
        if req.db_ref.startswith("pg_"):        msg = "PostgreSQL session expired (server restarted). Please reconnect."
        elif req.db_ref.startswith("mysql_"):   msg = "MySQL session expired (server restarted). Please reconnect."
        elif req.db_ref.startswith("mssql_"):   msg = "SQL Server session expired (server restarted). Please reconnect."
        elif req.db_ref.startswith("upload_"):  msg = "Uploaded DB session expired (server restarted). Please re-upload your file."
        else:                                   msg = f"Unknown db_ref: {req.db_ref}"
        yield _sse({"type": "error", "text": msg}); yield _sse({"type": "done"}); return

    cfg = get_provider_cfg(req.provider)
    key = os.environ.get(cfg["env"], "")
    if not key:
        yield _sse({"type": "error", "text": f"{cfg['env']} not configured on this server."})
        yield _sse({"type": "done"}); return

    safe_question = sanitize_question(req.question)
    schema: DBSchema = session["schema"]
    schema_text = schema_to_prompt_text(schema, safe_question)
    yield _sse({"type": "schema_loaded", "tables": len(schema.tables),
                "columns": sum(len(t.columns) for t in schema.tables.values())})

    sql = None; result = None; last_error = None; prev_sql = None
    for attempt in range(1, 4):
        if attempt > 1:
            await asyncio.sleep(2 ** (attempt - 2))
            yield _sse({"type": "retry", "attempt": attempt, "error": last_error})
        try:
            sql = await generate_sql(
                safe_question, schema_text, req.provider, key,
                prev_sql, last_error, req.history or None, req.glossary, req.correction,
            )
            validate_sql(sql)
        except RateLimitError as e:
            yield _sse({"type": "error", "text": str(e)}); yield _sse({"type": "done"}); return
        except UnsafeQueryError as e:
            last_error = str(e); prev_sql = sql; continue
        except Exception as e:
            last_error = f"SQL generation error: {e}"; prev_sql = sql; continue

        yield _sse({"type": "sql_generated", "sql": sql})
        try:
            stype = session["type"]
            paged = paginate_mssql_sql(sql, 1, 50) if stype == "mssql" else paginate_sql(sql, 1, 50)
            result = await exec_session(session, paged)
            break
        except Exception as e:
            last_error = f"Execution error: {e}"; prev_sql = sql; result = None; continue

    if result is None:
        yield _sse({"type": "error", "text": f"Failed after 3 attempts. Last: {last_error}"})
        yield _sse({"type": "done"}); return

    safe_result = mask_sensitive_columns(result)
    stype = session["type"]
    if stype == "sqlite":                          total_count = await count_rows_sqlite(session["path"], sql)
    elif stype in ("postgresql", "mysql", "mssql"): total_count = await count_rows_remote(session, sql)
    else:                                           total_count = -1
    result_dict = result_to_dict(safe_result)
    result_dict.update({"total_count": total_count, "page": 1, "page_size": 50})
    yield _sse({"type": "results", **result_dict})
    yield _sse({"type": "done"})


class ExplainRequest(BaseModel):
    question: str; sql: str; columns: list[str]; rows: list; provider: str = "groq"


@router.post("/sql/explain")
async def explain_sql(req: ExplainRequest):
    return StreamingResponse(_stream_explain(req), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})


async def _stream_explain(req: ExplainRequest) -> AsyncGenerator[str, None]:
    cfg = get_provider_cfg(req.provider)
    key = os.environ.get(cfg["env"], "")
    if not key: yield _sse({"type": "error", "text": f"{cfg['env']} not configured"}); yield _sse({"type": "done"}); return
    prompt = build_explain_prompt(sanitize_question(req.question), req.sql, req.columns, req.rows[:10])
    try:
        async for chunk in stream_explanation(prompt, req.provider, key): yield chunk
        sugg = await generate_followup_suggestions(req.question, req.sql, req.columns, req.rows[:5], req.provider, key)
        if sugg: yield _sse({"type": "suggestions", "questions": sugg})
    except RateLimitError as e:
        yield _sse({"type": "error", "text": str(e)})
    yield _sse({"type": "done"})


class ReasonRequest(BaseModel):
    question: str; sql: str; columns: list[str]; provider: str = "groq"


@router.post("/sql/reason")
async def reason_sql(req: ReasonRequest):
    return StreamingResponse(_stream_reason(req), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})


async def _stream_reason(req: ReasonRequest) -> AsyncGenerator[str, None]:
    cfg = get_provider_cfg(req.provider)
    key = os.environ.get(cfg["env"], "")
    if not key: yield _sse({"type": "error", "text": f"{cfg['env']} not configured"}); yield _sse({"type": "done"}); return
    cols = ", ".join(req.columns) or "unknown"
    prompt = (
        f"Question: {sanitize_question(req.question)}\n"
        f"SQL generated: {req.sql}\n"
        f"Result columns: {cols}\n\n"
        "Explain your step-by-step reasoning for generating this SQL:\n"
        "- What did you identify as the key intent of the question?\n"
        "- Which tables and columns did you choose and why?\n"
        "- What assumptions did you make (e.g. how a term maps to a column)?\n"
        "- Why did you use this aggregation / join / filter?\n"
        "Be concise but specific. Use plain English, not SQL."
    )
    try:
        async for chunk in stream_explanation(prompt, req.provider, key): yield chunk
    except RateLimitError as e:
        yield _sse({"type": "error", "text": str(e)})
    yield _sse({"type": "done"})