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.
@@ -0,0 +1,6 @@
1
+ from db_chat_widget.chat.engine import ChatEngine, ChatResult
2
+ from db_chat_widget.config import Settings
3
+
4
+ __version__ = "0.1.0"
5
+
6
+ __all__ = ["ChatEngine", "ChatResult", "Settings", "__version__"]
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())
@@ -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)