db-chat-widget 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- db_chat_widget/__init__.py +6 -0
- db_chat_widget/chat/__init__.py +0 -0
- db_chat_widget/chat/engine.py +99 -0
- db_chat_widget/cli.py +89 -0
- db_chat_widget/config.py +27 -0
- db_chat_widget/db/__init__.py +0 -0
- db_chat_widget/db/connection.py +24 -0
- db_chat_widget/db/executor.py +76 -0
- db_chat_widget/db/introspection.py +71 -0
- db_chat_widget/db/safety.py +60 -0
- db_chat_widget/llm/__init__.py +0 -0
- db_chat_widget/llm/anthropic_provider.py +30 -0
- db_chat_widget/llm/base.py +49 -0
- db_chat_widget/llm/factory.py +40 -0
- db_chat_widget/llm/groq_provider.py +32 -0
- db_chat_widget/llm/ollama_provider.py +40 -0
- db_chat_widget/llm/openai_provider.py +32 -0
- db_chat_widget/server/__init__.py +0 -0
- db_chat_widget/server/app.py +69 -0
- db_chat_widget/server/static/widget.css +253 -0
- db_chat_widget/server/static/widget.js +221 -0
- db_chat_widget/server/templates/demo.html +35 -0
- db_chat_widget-0.1.0.dist-info/METADATA +233 -0
- db_chat_widget-0.1.0.dist-info/RECORD +27 -0
- db_chat_widget-0.1.0.dist-info/WHEEL +4 -0
- db_chat_widget-0.1.0.dist-info/entry_points.txt +2 -0
- db_chat_widget-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Engine
|
|
7
|
+
|
|
8
|
+
from db_chat_widget.config import Settings
|
|
9
|
+
from db_chat_widget.db.connection import build_engine
|
|
10
|
+
from db_chat_widget.db.executor import run_safe_query
|
|
11
|
+
from db_chat_widget.db.introspection import get_schema, schema_to_prompt
|
|
12
|
+
from db_chat_widget.db.safety import UnsafeQueryError
|
|
13
|
+
from db_chat_widget.llm.base import LLMProvider
|
|
14
|
+
from db_chat_widget.llm.factory import create_llm_provider
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ChatResult:
|
|
19
|
+
question: str
|
|
20
|
+
sql: str | None
|
|
21
|
+
columns: list[str]
|
|
22
|
+
rows: list[dict[str, Any]]
|
|
23
|
+
truncated: bool
|
|
24
|
+
answer: str
|
|
25
|
+
error: str | None = None
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
return {
|
|
29
|
+
"question": self.question,
|
|
30
|
+
"sql": self.sql,
|
|
31
|
+
"columns": self.columns,
|
|
32
|
+
"rows": self.rows,
|
|
33
|
+
"truncated": self.truncated,
|
|
34
|
+
"answer": self.answer,
|
|
35
|
+
"error": self.error,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ChatEngine:
|
|
40
|
+
"""Orchestrates: question -> SQL generation -> safety check -> execution -> answer."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
settings: Settings,
|
|
45
|
+
llm_provider: LLMProvider | None = None,
|
|
46
|
+
engine: Engine | None = None,
|
|
47
|
+
):
|
|
48
|
+
self.settings = settings
|
|
49
|
+
self.engine = engine or build_engine(settings.db_url)
|
|
50
|
+
self.llm = llm_provider or create_llm_provider(settings)
|
|
51
|
+
self._dialect = self.engine.dialect.name
|
|
52
|
+
|
|
53
|
+
def ask(self, question: str) -> ChatResult:
|
|
54
|
+
schema_tables = get_schema(self.engine, allowed_tables=self.settings.allowed_tables)
|
|
55
|
+
schema_prompt = schema_to_prompt(schema_tables)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
sql = self.llm.generate_sql(question, schema_prompt, self._dialect)
|
|
59
|
+
except Exception as exc: # noqa: BLE001
|
|
60
|
+
return ChatResult(
|
|
61
|
+
question=question, sql=None, columns=[], rows=[], truncated=False,
|
|
62
|
+
answer="Sorry, I couldn't process that question.", error=f"LLM error: {exc}",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if sql.strip().upper() == "CANNOT_ANSWER" or not sql.strip():
|
|
66
|
+
return ChatResult(
|
|
67
|
+
question=question, sql=None, columns=[], rows=[], truncated=False,
|
|
68
|
+
answer="I can't answer that from the available database schema.",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = run_safe_query(
|
|
73
|
+
self.engine,
|
|
74
|
+
sql,
|
|
75
|
+
read_only=self.settings.read_only,
|
|
76
|
+
allowed_tables=self.settings.allowed_tables,
|
|
77
|
+
max_rows=self.settings.max_rows,
|
|
78
|
+
query_timeout_seconds=self.settings.query_timeout_seconds,
|
|
79
|
+
)
|
|
80
|
+
except UnsafeQueryError as exc:
|
|
81
|
+
return ChatResult(
|
|
82
|
+
question=question, sql=sql, columns=[], rows=[], truncated=False,
|
|
83
|
+
answer="The generated query was blocked for safety reasons.", error=str(exc),
|
|
84
|
+
)
|
|
85
|
+
except Exception as exc: # noqa: BLE001
|
|
86
|
+
return ChatResult(
|
|
87
|
+
question=question, sql=sql, columns=[], rows=[], truncated=False,
|
|
88
|
+
answer="The query failed to execute.", error=str(exc),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
answer = self.llm.summarize(question, result.sql, result.columns, result.rows)
|
|
92
|
+
return ChatResult(
|
|
93
|
+
question=question,
|
|
94
|
+
sql=result.sql,
|
|
95
|
+
columns=result.columns,
|
|
96
|
+
rows=result.rows,
|
|
97
|
+
truncated=result.truncated,
|
|
98
|
+
answer=answer,
|
|
99
|
+
)
|
db_chat_widget/cli.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from db_chat_widget.config import Settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="db-chat-widget", description="Embeddable database chatbot widget."
|
|
13
|
+
)
|
|
14
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
15
|
+
|
|
16
|
+
serve = subparsers.add_parser("serve", help="Start the chatbot API + demo server.")
|
|
17
|
+
serve.add_argument(
|
|
18
|
+
"--db-url", default=os.environ.get("DB_CHAT_DB_URL"), help="SQLAlchemy database URL."
|
|
19
|
+
)
|
|
20
|
+
serve.add_argument(
|
|
21
|
+
"--llm-provider",
|
|
22
|
+
default=os.environ.get("DB_CHAT_LLM_PROVIDER", "anthropic"),
|
|
23
|
+
choices=["anthropic", "openai", "groq", "ollama"],
|
|
24
|
+
)
|
|
25
|
+
serve.add_argument("--llm-model", default=os.environ.get("DB_CHAT_LLM_MODEL"))
|
|
26
|
+
serve.add_argument("--llm-api-key", default=os.environ.get("DB_CHAT_LLM_API_KEY"))
|
|
27
|
+
serve.add_argument("--llm-base-url", default=os.environ.get("DB_CHAT_LLM_BASE_URL"))
|
|
28
|
+
serve.add_argument("--host", default=os.environ.get("DB_CHAT_HOST", "127.0.0.1"))
|
|
29
|
+
serve.add_argument("--port", type=int, default=int(os.environ.get("DB_CHAT_PORT", "8000")))
|
|
30
|
+
serve.add_argument(
|
|
31
|
+
"--max-rows", type=int, default=int(os.environ.get("DB_CHAT_MAX_ROWS", "200"))
|
|
32
|
+
)
|
|
33
|
+
serve.add_argument(
|
|
34
|
+
"--allow-write",
|
|
35
|
+
action="store_true",
|
|
36
|
+
help="Allow non-SELECT statements (disabled by default for safety).",
|
|
37
|
+
)
|
|
38
|
+
serve.add_argument(
|
|
39
|
+
"--allowed-table",
|
|
40
|
+
action="append",
|
|
41
|
+
dest="allowed_tables",
|
|
42
|
+
default=None,
|
|
43
|
+
help="Restrict the chatbot to this table; repeat the flag for multiple tables.",
|
|
44
|
+
)
|
|
45
|
+
serve.add_argument("--cors-origin", action="append", dest="cors_origins", default=None)
|
|
46
|
+
serve.add_argument("--reload", action="store_true")
|
|
47
|
+
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv: list[str] | None = None) -> int:
|
|
52
|
+
parser = _build_parser()
|
|
53
|
+
args = parser.parse_args(argv)
|
|
54
|
+
|
|
55
|
+
if args.command == "serve":
|
|
56
|
+
if not args.db_url:
|
|
57
|
+
parser.error("--db-url is required (or set DB_CHAT_DB_URL).")
|
|
58
|
+
|
|
59
|
+
settings = Settings(
|
|
60
|
+
db_url=args.db_url,
|
|
61
|
+
llm_provider=args.llm_provider,
|
|
62
|
+
llm_model=args.llm_model,
|
|
63
|
+
llm_api_key=args.llm_api_key,
|
|
64
|
+
llm_base_url=args.llm_base_url,
|
|
65
|
+
read_only=not args.allow_write,
|
|
66
|
+
max_rows=args.max_rows,
|
|
67
|
+
allowed_tables=args.allowed_tables,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
import uvicorn
|
|
72
|
+
|
|
73
|
+
from db_chat_widget.server.app import create_app
|
|
74
|
+
except ImportError as exc:
|
|
75
|
+
parser.error(
|
|
76
|
+
"The 'serve' command requires the optional server dependencies. "
|
|
77
|
+
f"Install them with: pip install db-chat-widget[server] ({exc})"
|
|
78
|
+
)
|
|
79
|
+
return 1
|
|
80
|
+
|
|
81
|
+
app = create_app(settings, cors_origins=args.cors_origins)
|
|
82
|
+
uvicorn.run(app, host=args.host, port=args.port, reload=args.reload)
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
return 1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
sys.exit(main())
|
db_chat_widget/config.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Settings:
|
|
8
|
+
"""Runtime configuration for the chatbot engine and server.
|
|
9
|
+
|
|
10
|
+
db_url: SQLAlchemy connection URL, e.g. "postgresql://user:pass@host/db"
|
|
11
|
+
llm_provider: one of "anthropic", "openai", "ollama"
|
|
12
|
+
llm_model: provider-specific model name; a sane default is picked per provider if omitted
|
|
13
|
+
llm_api_key: API key for the provider (not required for "ollama")
|
|
14
|
+
read_only: when True (default), only SELECT statements are allowed
|
|
15
|
+
max_rows: maximum number of rows returned from any query
|
|
16
|
+
allowed_tables: optional allowlist restricting which tables the LLM may query
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
db_url: str
|
|
20
|
+
llm_provider: str = "anthropic"
|
|
21
|
+
llm_model: str | None = None
|
|
22
|
+
llm_api_key: str | None = None
|
|
23
|
+
llm_base_url: str | None = None
|
|
24
|
+
read_only: bool = True
|
|
25
|
+
max_rows: int = 200
|
|
26
|
+
allowed_tables: list[str] | None = field(default=None)
|
|
27
|
+
query_timeout_seconds: int = 15
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Engine, create_engine
|
|
4
|
+
from sqlalchemy.engine import make_url
|
|
5
|
+
|
|
6
|
+
# Default client encoding applied when the database URL doesn't specify one.
|
|
7
|
+
# MySQL/MariaDB servers commonly default to latin1, which mangles accented
|
|
8
|
+
# characters (e.g. French text) unless the client explicitly asks for UTF-8.
|
|
9
|
+
_DEFAULT_MYSQL_CHARSET = "utf8mb4"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_engine(db_url: str, **engine_kwargs: object) -> Engine:
|
|
13
|
+
"""Create a SQLAlchemy engine, applying a sane default client encoding.
|
|
14
|
+
|
|
15
|
+
For MySQL/MariaDB URLs without an explicit `charset` query parameter,
|
|
16
|
+
`charset=utf8mb4` is added so text data round-trips correctly by default.
|
|
17
|
+
Any encoding explicitly set in `db_url` is left untouched.
|
|
18
|
+
"""
|
|
19
|
+
url = make_url(db_url)
|
|
20
|
+
|
|
21
|
+
if url.get_backend_name() == "mysql" and "charset" not in url.query:
|
|
22
|
+
url = url.update_query_dict({"charset": _DEFAULT_MYSQL_CHARSET})
|
|
23
|
+
|
|
24
|
+
return create_engine(url, **engine_kwargs)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import sqlglot
|
|
7
|
+
from sqlalchemy import Engine, text
|
|
8
|
+
from sqlglot import exp
|
|
9
|
+
|
|
10
|
+
from db_chat_widget.db.safety import UnsafeQueryError, validate_select_only
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class QueryResult:
|
|
15
|
+
columns: list[str]
|
|
16
|
+
rows: list[dict[str, Any]]
|
|
17
|
+
truncated: bool
|
|
18
|
+
sql: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _apply_row_limit(sql: str, max_rows: int, dialect: str | None) -> tuple[str, str]:
|
|
22
|
+
"""Cap the query at `max_rows` results.
|
|
23
|
+
|
|
24
|
+
Returns (exec_sql, display_sql). exec_sql requests one extra row (max_rows + 1) so
|
|
25
|
+
truncation can be detected; display_sql reports the effective LIMIT (max_rows) so the
|
|
26
|
+
off-by-one fetch trick isn't leaked to end users. If the query already has a smaller
|
|
27
|
+
LIMIT, that LIMIT is left untouched in both and is never reported as truncated.
|
|
28
|
+
"""
|
|
29
|
+
parsed = sqlglot.parse_one(sql, read=dialect)
|
|
30
|
+
existing_limit = parsed.args.get("limit")
|
|
31
|
+
if existing_limit is not None:
|
|
32
|
+
try:
|
|
33
|
+
current = int(existing_limit.expression.this)
|
|
34
|
+
if current <= max_rows:
|
|
35
|
+
unchanged = parsed.sql(dialect=dialect)
|
|
36
|
+
return unchanged, unchanged
|
|
37
|
+
except (TypeError, ValueError, AttributeError):
|
|
38
|
+
pass
|
|
39
|
+
exec_sql = parsed.copy().limit(max_rows + 1).sql(dialect=dialect)
|
|
40
|
+
display_sql = parsed.copy().limit(max_rows).sql(dialect=dialect)
|
|
41
|
+
return exec_sql, display_sql
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_safe_query(
|
|
45
|
+
engine: Engine,
|
|
46
|
+
sql: str,
|
|
47
|
+
*,
|
|
48
|
+
read_only: bool = True,
|
|
49
|
+
allowed_tables: list[str] | None = None,
|
|
50
|
+
max_rows: int = 200,
|
|
51
|
+
query_timeout_seconds: int = 15,
|
|
52
|
+
) -> QueryResult:
|
|
53
|
+
"""Validate and execute a single SELECT statement, capping returned rows."""
|
|
54
|
+
dialect = engine.dialect.name if engine.dialect.name != "sqlite" else None
|
|
55
|
+
|
|
56
|
+
if read_only:
|
|
57
|
+
safe_sql = validate_select_only(sql, allowed_tables=allowed_tables, dialect=dialect)
|
|
58
|
+
else:
|
|
59
|
+
parsed = sqlglot.parse_one(sql, read=dialect)
|
|
60
|
+
if not isinstance(parsed, (exp.Select, exp.Union, exp.With)):
|
|
61
|
+
raise UnsafeQueryError("Only a single statement is allowed.")
|
|
62
|
+
safe_sql = parsed.sql(dialect=dialect)
|
|
63
|
+
|
|
64
|
+
exec_sql, display_sql = _apply_row_limit(safe_sql, max_rows, dialect)
|
|
65
|
+
|
|
66
|
+
with engine.connect() as conn:
|
|
67
|
+
if engine.dialect.name == "postgresql" and read_only:
|
|
68
|
+
conn = conn.execution_options(postgresql_readonly=True)
|
|
69
|
+
cursor_result = conn.execute(text(exec_sql))
|
|
70
|
+
columns = list(cursor_result.keys())
|
|
71
|
+
fetched = cursor_result.fetchmany(max_rows + 1)
|
|
72
|
+
|
|
73
|
+
truncated = len(fetched) > max_rows
|
|
74
|
+
rows = [dict(zip(columns, row, strict=True)) for row in fetched[:max_rows]]
|
|
75
|
+
|
|
76
|
+
return QueryResult(columns=columns, rows=rows, truncated=truncated, sql=display_sql)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import Engine, inspect
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ColumnInfo:
|
|
10
|
+
name: str
|
|
11
|
+
type: str
|
|
12
|
+
nullable: bool
|
|
13
|
+
primary_key: bool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TableInfo:
|
|
18
|
+
name: str
|
|
19
|
+
columns: list[ColumnInfo]
|
|
20
|
+
foreign_keys: list[str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_schema(engine: Engine, allowed_tables: list[str] | None = None) -> list[TableInfo]:
|
|
24
|
+
"""Introspect the database and return table/column metadata.
|
|
25
|
+
|
|
26
|
+
If allowed_tables is given, only those tables are included.
|
|
27
|
+
"""
|
|
28
|
+
inspector = inspect(engine)
|
|
29
|
+
tables: list[TableInfo] = []
|
|
30
|
+
|
|
31
|
+
for table_name in inspector.get_table_names():
|
|
32
|
+
if allowed_tables is not None and table_name not in allowed_tables:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
pk_columns = set(inspector.get_pk_constraint(table_name).get("constrained_columns") or [])
|
|
36
|
+
columns = [
|
|
37
|
+
ColumnInfo(
|
|
38
|
+
name=col["name"],
|
|
39
|
+
type=str(col["type"]),
|
|
40
|
+
nullable=bool(col.get("nullable", True)),
|
|
41
|
+
primary_key=col["name"] in pk_columns,
|
|
42
|
+
)
|
|
43
|
+
for col in inspector.get_columns(table_name)
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
fk_descriptions = []
|
|
47
|
+
for fk in inspector.get_foreign_keys(table_name):
|
|
48
|
+
if not fk.get("constrained_columns") or not fk.get("referred_table"):
|
|
49
|
+
continue
|
|
50
|
+
local_cols = ", ".join(fk["constrained_columns"])
|
|
51
|
+
ref_cols = ", ".join(fk.get("referred_columns") or [])
|
|
52
|
+
fk_descriptions.append(f"{local_cols} -> {fk['referred_table']}({ref_cols})")
|
|
53
|
+
|
|
54
|
+
tables.append(TableInfo(name=table_name, columns=columns, foreign_keys=fk_descriptions))
|
|
55
|
+
|
|
56
|
+
return tables
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def schema_to_prompt(tables: list[TableInfo]) -> str:
|
|
60
|
+
"""Render schema metadata as compact text suitable for an LLM prompt."""
|
|
61
|
+
lines: list[str] = []
|
|
62
|
+
for table in tables:
|
|
63
|
+
col_parts = []
|
|
64
|
+
for col in table.columns:
|
|
65
|
+
marker = " PK" if col.primary_key else ""
|
|
66
|
+
null_marker = "" if col.nullable else " NOT NULL"
|
|
67
|
+
col_parts.append(f"{col.name} {col.type}{marker}{null_marker}")
|
|
68
|
+
lines.append(f"TABLE {table.name} ({', '.join(col_parts)})")
|
|
69
|
+
for fk in table.foreign_keys:
|
|
70
|
+
lines.append(f" FK {fk}")
|
|
71
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlglot
|
|
4
|
+
from sqlglot import exp
|
|
5
|
+
|
|
6
|
+
_WRITE_EXPRESSION_TYPES = (
|
|
7
|
+
exp.Insert,
|
|
8
|
+
exp.Update,
|
|
9
|
+
exp.Delete,
|
|
10
|
+
exp.Drop,
|
|
11
|
+
exp.Create,
|
|
12
|
+
exp.Alter,
|
|
13
|
+
exp.TruncateTable,
|
|
14
|
+
exp.Merge,
|
|
15
|
+
exp.Grant,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UnsafeQueryError(ValueError):
|
|
20
|
+
"""Raised when a generated SQL statement fails the safety checks."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_select_only(
|
|
24
|
+
sql: str, *, allowed_tables: list[str] | None = None, dialect: str | None = None
|
|
25
|
+
) -> str:
|
|
26
|
+
"""Ensure `sql` contains exactly one read-only SELECT statement.
|
|
27
|
+
|
|
28
|
+
Raises UnsafeQueryError if the query writes data, runs DDL, contains
|
|
29
|
+
multiple statements, or references a table outside `allowed_tables`.
|
|
30
|
+
Returns the normalized (single) statement on success.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
statements = sqlglot.parse(sql, read=dialect)
|
|
34
|
+
except Exception as exc: # noqa: BLE001 - surface parser errors as unsafe
|
|
35
|
+
raise UnsafeQueryError(f"Could not parse SQL: {exc}") from exc
|
|
36
|
+
|
|
37
|
+
statements = [s for s in statements if s is not None]
|
|
38
|
+
if len(statements) == 0:
|
|
39
|
+
raise UnsafeQueryError("No SQL statement found.")
|
|
40
|
+
if len(statements) > 1:
|
|
41
|
+
raise UnsafeQueryError("Only a single SQL statement is allowed.")
|
|
42
|
+
|
|
43
|
+
statement = statements[0]
|
|
44
|
+
|
|
45
|
+
if not isinstance(statement, (exp.Select, exp.Union, exp.With)):
|
|
46
|
+
kind = type(statement).__name__
|
|
47
|
+
raise UnsafeQueryError(f"Only SELECT statements are allowed, got: {kind}")
|
|
48
|
+
|
|
49
|
+
for write_type in _WRITE_EXPRESSION_TYPES:
|
|
50
|
+
if list(statement.find_all(write_type)):
|
|
51
|
+
raise UnsafeQueryError(f"Query contains a disallowed operation: {write_type.__name__}")
|
|
52
|
+
|
|
53
|
+
if allowed_tables is not None:
|
|
54
|
+
referenced = {table.name for table in statement.find_all(exp.Table)}
|
|
55
|
+
disallowed = referenced - set(allowed_tables)
|
|
56
|
+
if disallowed:
|
|
57
|
+
names = ", ".join(sorted(disallowed))
|
|
58
|
+
raise UnsafeQueryError(f"Query references disallowed table(s): {names}")
|
|
59
|
+
|
|
60
|
+
return statement.sql(dialect=dialect)
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from db_chat_widget.llm.base import SYSTEM_PROMPT_TEMPLATE, LLMProvider
|
|
4
|
+
|
|
5
|
+
DEFAULT_MODEL = "claude-sonnet-5"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AnthropicProvider(LLMProvider):
|
|
9
|
+
def __init__(self, api_key: str, model: str | None = None):
|
|
10
|
+
try:
|
|
11
|
+
import anthropic
|
|
12
|
+
except ImportError as exc: # pragma: no cover
|
|
13
|
+
raise ImportError(
|
|
14
|
+
"The 'anthropic' package is required for AnthropicProvider. "
|
|
15
|
+
"Install it with: pip install anthropic"
|
|
16
|
+
) from exc
|
|
17
|
+
|
|
18
|
+
self._client = anthropic.Anthropic(api_key=api_key)
|
|
19
|
+
self._model = model or DEFAULT_MODEL
|
|
20
|
+
|
|
21
|
+
def generate_sql(self, question: str, schema_prompt: str, dialect: str) -> str:
|
|
22
|
+
system = SYSTEM_PROMPT_TEMPLATE.format(dialect=dialect, schema=schema_prompt)
|
|
23
|
+
response = self._client.messages.create(
|
|
24
|
+
model=self._model,
|
|
25
|
+
max_tokens=512,
|
|
26
|
+
system=system,
|
|
27
|
+
messages=[{"role": "user", "content": question}],
|
|
28
|
+
)
|
|
29
|
+
text = "".join(block.text for block in response.content if hasattr(block, "text"))
|
|
30
|
+
return self._strip_sql(text)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
SYSTEM_PROMPT_TEMPLATE = """You are a database assistant. Given a database schema and a \
|
|
6
|
+
question in natural language, respond with a single {dialect} SQL SELECT statement that \
|
|
7
|
+
answers the question.
|
|
8
|
+
|
|
9
|
+
Rules:
|
|
10
|
+
- Output ONLY the SQL statement, no explanation, no markdown code fences.
|
|
11
|
+
- Only use SELECT statements. Never write, alter, or delete data.
|
|
12
|
+
- Only reference tables and columns that appear in the schema below.
|
|
13
|
+
- If the question cannot be answered with the given schema, output exactly: \
|
|
14
|
+
CANNOT_ANSWER
|
|
15
|
+
|
|
16
|
+
Schema:
|
|
17
|
+
{schema}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LLMProvider(ABC):
|
|
22
|
+
"""Abstract interface for a natural-language-to-SQL backend."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def generate_sql(self, question: str, schema_prompt: str, dialect: str) -> str:
|
|
26
|
+
"""Return a single SQL SELECT statement answering `question`, or "CANNOT_ANSWER"."""
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
|
|
29
|
+
def summarize(self, question: str, sql: str, columns: list[str], rows: list[dict]) -> str:
|
|
30
|
+
"""Produce a short natural-language summary of the query result.
|
|
31
|
+
|
|
32
|
+
Default implementation is a plain, deterministic sentence (no extra LLM call).
|
|
33
|
+
Providers may override this to ask the model for a nicer summary.
|
|
34
|
+
"""
|
|
35
|
+
count = len(rows)
|
|
36
|
+
if count == 0:
|
|
37
|
+
return "No rows matched your question."
|
|
38
|
+
if count == 1:
|
|
39
|
+
return f"Found 1 result: {rows[0]}"
|
|
40
|
+
return f"Found {count} result(s) across columns: {', '.join(columns)}."
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _strip_sql(text: str) -> str:
|
|
44
|
+
cleaned = text.strip()
|
|
45
|
+
if cleaned.startswith("```"):
|
|
46
|
+
cleaned = cleaned.strip("`")
|
|
47
|
+
if cleaned.lower().startswith("sql"):
|
|
48
|
+
cleaned = cleaned[3:]
|
|
49
|
+
return cleaned.strip().rstrip(";").strip()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from db_chat_widget.config import Settings
|
|
4
|
+
from db_chat_widget.llm.base import LLMProvider
|
|
5
|
+
|
|
6
|
+
_PROVIDERS = ("anthropic", "openai", "groq", "ollama")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_llm_provider(settings: Settings) -> LLMProvider:
|
|
10
|
+
provider = settings.llm_provider.lower()
|
|
11
|
+
|
|
12
|
+
if provider == "anthropic":
|
|
13
|
+
from db_chat_widget.llm.anthropic_provider import AnthropicProvider
|
|
14
|
+
|
|
15
|
+
if not settings.llm_api_key:
|
|
16
|
+
raise ValueError("llm_api_key is required for the 'anthropic' provider.")
|
|
17
|
+
return AnthropicProvider(api_key=settings.llm_api_key, model=settings.llm_model)
|
|
18
|
+
|
|
19
|
+
if provider == "openai":
|
|
20
|
+
from db_chat_widget.llm.openai_provider import OpenAIProvider
|
|
21
|
+
|
|
22
|
+
if not settings.llm_api_key:
|
|
23
|
+
raise ValueError("llm_api_key is required for the 'openai' provider.")
|
|
24
|
+
return OpenAIProvider(api_key=settings.llm_api_key, model=settings.llm_model)
|
|
25
|
+
|
|
26
|
+
if provider == "groq":
|
|
27
|
+
from db_chat_widget.llm.groq_provider import GroqProvider
|
|
28
|
+
|
|
29
|
+
if not settings.llm_api_key:
|
|
30
|
+
raise ValueError("llm_api_key is required for the 'groq' provider.")
|
|
31
|
+
return GroqProvider(api_key=settings.llm_api_key, model=settings.llm_model)
|
|
32
|
+
|
|
33
|
+
if provider == "ollama":
|
|
34
|
+
from db_chat_widget.llm.ollama_provider import OllamaProvider
|
|
35
|
+
|
|
36
|
+
return OllamaProvider(base_url=settings.llm_base_url, model=settings.llm_model)
|
|
37
|
+
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Unknown llm_provider '{settings.llm_provider}'. Expected one of {_PROVIDERS}."
|
|
40
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from db_chat_widget.llm.base import SYSTEM_PROMPT_TEMPLATE, LLMProvider
|
|
4
|
+
|
|
5
|
+
DEFAULT_MODEL = "llama-3.3-70b-versatile"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GroqProvider(LLMProvider):
|
|
9
|
+
def __init__(self, api_key: str, model: str | None = None):
|
|
10
|
+
try:
|
|
11
|
+
import groq
|
|
12
|
+
except ImportError as exc: # pragma: no cover
|
|
13
|
+
raise ImportError(
|
|
14
|
+
"The 'groq' package is required for GroqProvider. "
|
|
15
|
+
"Install it with: pip install groq"
|
|
16
|
+
) from exc
|
|
17
|
+
|
|
18
|
+
self._client = groq.Groq(api_key=api_key)
|
|
19
|
+
self._model = model or DEFAULT_MODEL
|
|
20
|
+
|
|
21
|
+
def generate_sql(self, question: str, schema_prompt: str, dialect: str) -> str:
|
|
22
|
+
system = SYSTEM_PROMPT_TEMPLATE.format(dialect=dialect, schema=schema_prompt)
|
|
23
|
+
response = self._client.chat.completions.create(
|
|
24
|
+
model=self._model,
|
|
25
|
+
messages=[
|
|
26
|
+
{"role": "system", "content": system},
|
|
27
|
+
{"role": "user", "content": question},
|
|
28
|
+
],
|
|
29
|
+
temperature=0,
|
|
30
|
+
)
|
|
31
|
+
text = response.choices[0].message.content or ""
|
|
32
|
+
return self._strip_sql(text)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from db_chat_widget.llm.base import SYSTEM_PROMPT_TEMPLATE, LLMProvider
|
|
4
|
+
|
|
5
|
+
DEFAULT_MODEL = "llama3.1"
|
|
6
|
+
DEFAULT_BASE_URL = "http://localhost:11434"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OllamaProvider(LLMProvider):
|
|
10
|
+
def __init__(self, base_url: str | None = None, model: str | None = None):
|
|
11
|
+
try:
|
|
12
|
+
import httpx
|
|
13
|
+
except ImportError as exc: # pragma: no cover
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"The 'httpx' package is required for OllamaProvider. "
|
|
16
|
+
"Install it with: pip install httpx"
|
|
17
|
+
) from exc
|
|
18
|
+
|
|
19
|
+
self._httpx = httpx
|
|
20
|
+
self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
21
|
+
self._model = model or DEFAULT_MODEL
|
|
22
|
+
|
|
23
|
+
def generate_sql(self, question: str, schema_prompt: str, dialect: str) -> str:
|
|
24
|
+
system = SYSTEM_PROMPT_TEMPLATE.format(dialect=dialect, schema=schema_prompt)
|
|
25
|
+
response = self._httpx.post(
|
|
26
|
+
f"{self._base_url}/api/chat",
|
|
27
|
+
json={
|
|
28
|
+
"model": self._model,
|
|
29
|
+
"stream": False,
|
|
30
|
+
"options": {"temperature": 0},
|
|
31
|
+
"messages": [
|
|
32
|
+
{"role": "system", "content": system},
|
|
33
|
+
{"role": "user", "content": question},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
timeout=60,
|
|
37
|
+
)
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
text = response.json()["message"]["content"]
|
|
40
|
+
return self._strip_sql(text)
|