text2sql-engine 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.
text2sql/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ """text2sql — schema-agnostic, config-driven Text-to-SQL library.
2
+
3
+ Entry point::
4
+
5
+ from text2sql import Text2SQL
6
+ engine = Text2SQL.from_config()
7
+ result = engine.run("get me the top 5 best-selling products last month")
8
+ print(result.sql)
9
+ print(result.linked_schema)
10
+ print(result.metadata)
11
+
12
+ The library only produces a SQL string. It never connects to or runs against a
13
+ database.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .errors import (
19
+ ConfigError,
20
+ EmptyLinkingError,
21
+ ProviderError,
22
+ SchemaError,
23
+ StructuredOutputError,
24
+ Text2SQLError,
25
+ )
26
+ from .pipeline import Text2SQL
27
+ from .prompts import PromptTemplates
28
+ from .providers import LLMProvider, register_provider
29
+ from .schema import FileSchemaProvider, Schema, SchemaProvider
30
+ from .types import (
31
+ LinkedColumn,
32
+ LinkedJoin,
33
+ LinkedSchema,
34
+ RunMetadata,
35
+ StageMetadata,
36
+ Text2SQLResult,
37
+ TokenUsage,
38
+ )
39
+
40
+ __version__ = "0.1.0"
41
+
42
+ __all__ = [
43
+ "Text2SQL",
44
+ # Schema
45
+ "Schema",
46
+ "SchemaProvider",
47
+ "FileSchemaProvider",
48
+ # Providers / DI
49
+ "LLMProvider",
50
+ "register_provider",
51
+ "PromptTemplates",
52
+ # Result types
53
+ "Text2SQLResult",
54
+ "LinkedSchema",
55
+ "LinkedColumn",
56
+ "LinkedJoin",
57
+ "RunMetadata",
58
+ "StageMetadata",
59
+ "TokenUsage",
60
+ # Errors
61
+ "Text2SQLError",
62
+ "ConfigError",
63
+ "SchemaError",
64
+ "ProviderError",
65
+ "StructuredOutputError",
66
+ "EmptyLinkingError",
67
+ ]
text2sql/config.py ADDED
@@ -0,0 +1,251 @@
1
+ """Typed config.
2
+
3
+ Two separate sources merge into one typed :class:`Settings`:
4
+
5
+ * ``.env`` -> secrets, paths, provider/model selection.
6
+ * ``config.toml`` -> behavioral parameters. No magic numbers in code.
7
+
8
+ Loading fails fast with a clear :class:`~text2sql.errors.ConfigError` when a
9
+ required ``.env`` variable is missing or ``config.toml`` is broken.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import tomllib
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from dotenv import load_dotenv
21
+
22
+ from .errors import ConfigError
23
+
24
+ # .env keys, declared once so they are easy to audit and document.
25
+ ENV_SCHEMA_PATH = "SCHEMA_PATH"
26
+ ENV_PROMPT_DIR = "PROMPT_DIR"
27
+ ENV_CONFIG_PATH = "CONFIG_PATH"
28
+
29
+ ENV_LINKING_PROVIDER = "LINKING_PROVIDER"
30
+ ENV_LINKING_MODEL = "LINKING_MODEL"
31
+ ENV_LINKING_BASE_URL = "LINKING_BASE_URL"
32
+
33
+ ENV_SQL_PROVIDER = "SQL_PROVIDER"
34
+ ENV_SQL_MODEL = "SQL_MODEL"
35
+ ENV_SQL_BASE_URL = "SQL_BASE_URL"
36
+
37
+ # API-key variable per provider. A provider needs its key only if a stage uses it.
38
+ PROVIDER_KEY_ENV = {
39
+ "openai": "OPENAI_API_KEY",
40
+ "anthropic": "ANTHROPIC_API_KEY",
41
+ }
42
+
43
+
44
+ @dataclass
45
+ class StageSettings:
46
+ """Sampling and behavior for one stage."""
47
+
48
+ temperature: float = 0.0
49
+ top_p: float = 1.0
50
+ max_tokens: int = 1024
51
+ prompt_template: str = ""
52
+ timeout_seconds: float = 60.0
53
+ # Used by linking only. Generation ignores it.
54
+ max_tables: int = 8
55
+
56
+
57
+ @dataclass
58
+ class RetrySettings:
59
+ """Retry/backoff, shared by both stages."""
60
+
61
+ max_attempts: int = 3
62
+ backoff_seconds: float = 1.0
63
+ backoff_factor: float = 2.0
64
+
65
+
66
+ @dataclass
67
+ class GeneralSettings:
68
+ """Top-level behavior."""
69
+
70
+ sql_dialect: str = "postgres"
71
+ log_level: str = "INFO"
72
+ strict_json: bool = True
73
+
74
+
75
+ @dataclass
76
+ class ProviderSelection:
77
+ """Which provider/model/endpoint a stage uses. Comes from ``.env``."""
78
+
79
+ provider: str
80
+ model: str
81
+ base_url: str | None = None
82
+ api_key: str | None = None
83
+
84
+
85
+ @dataclass
86
+ class Settings:
87
+ """Full config for a run."""
88
+
89
+ # Paths (.env)
90
+ schema_path: Path
91
+ # None -> use the prompt templates packaged inside text2sql.prompts.
92
+ prompt_dir: Path | None
93
+
94
+ # Provider selection (.env)
95
+ linking_selection: ProviderSelection
96
+ sql_selection: ProviderSelection
97
+
98
+ # Behavior (config.toml)
99
+ general: GeneralSettings = field(default_factory=GeneralSettings)
100
+ linking: StageSettings = field(default_factory=StageSettings)
101
+ generation: StageSettings = field(default_factory=StageSettings)
102
+ retry: RetrySettings = field(default_factory=RetrySettings)
103
+
104
+
105
+ def _require_env(key: str) -> str:
106
+ """Return an env var, or raise a clear ConfigError if it is missing."""
107
+ value = os.environ.get(key)
108
+ if value is None or value.strip() == "":
109
+ raise ConfigError(
110
+ f"Required environment variable '{key}' is missing or empty. "
111
+ f"See .env.example for the full list."
112
+ )
113
+ return value
114
+
115
+
116
+ def _optional_env(key: str) -> str | None:
117
+ value = os.environ.get(key)
118
+ if value is None or value.strip() == "":
119
+ return None
120
+ return value
121
+
122
+
123
+ def _load_toml(config_path: Path) -> dict[str, Any]:
124
+ if not config_path.is_file():
125
+ raise ConfigError(f"config.toml not found at '{config_path}'.")
126
+ try:
127
+ with config_path.open("rb") as fh:
128
+ return tomllib.load(fh)
129
+ except tomllib.TOMLDecodeError as exc:
130
+ raise ConfigError(f"Failed to parse config.toml at '{config_path}': {exc}") from exc
131
+
132
+
133
+ def _stage_from_toml(
134
+ table: dict[str, Any],
135
+ *,
136
+ default_template: str,
137
+ include_max_tables: bool,
138
+ ) -> StageSettings:
139
+ """Build a StageSettings. Falls back to dataclass defaults per key."""
140
+ defaults = StageSettings()
141
+ return StageSettings(
142
+ temperature=float(table.get("temperature", defaults.temperature)),
143
+ top_p=float(table.get("top_p", defaults.top_p)),
144
+ max_tokens=int(table.get("max_tokens", defaults.max_tokens)),
145
+ prompt_template=str(table.get("prompt_template", default_template)),
146
+ timeout_seconds=float(table.get("timeout_seconds", defaults.timeout_seconds)),
147
+ max_tables=(
148
+ int(table.get("max_tables", defaults.max_tables))
149
+ if include_max_tables
150
+ else defaults.max_tables
151
+ ),
152
+ )
153
+
154
+
155
+ def _selection_for_stage(
156
+ provider_key: str,
157
+ model_key: str,
158
+ base_url_key: str,
159
+ ) -> ProviderSelection:
160
+ provider = _require_env(provider_key).strip().lower()
161
+ model = _require_env(model_key)
162
+ base_url = _optional_env(base_url_key)
163
+
164
+ # Pick the API key for the selected provider, if we know one for it.
165
+ key_env = PROVIDER_KEY_ENV.get(provider)
166
+ api_key = _optional_env(key_env) if key_env else None
167
+
168
+ return ProviderSelection(
169
+ provider=provider,
170
+ model=model,
171
+ base_url=base_url,
172
+ api_key=api_key,
173
+ )
174
+
175
+
176
+ def load_settings(
177
+ env_file: str | os.PathLike[str] | None = None,
178
+ config_path: str | os.PathLike[str] | None = None,
179
+ ) -> Settings:
180
+ """Load and merge ``.env`` and ``config.toml`` into a :class:`Settings`.
181
+
182
+ Args:
183
+ env_file: Optional path to a ``.env`` file. If ``None``, dotenv's default
184
+ lookup is used (nearest ``.env`` walking up from CWD). Real process
185
+ env vars always win over the file.
186
+ config_path: Optional path to ``config.toml``. If ``None``, uses the
187
+ ``CONFIG_PATH`` env var, else ``./config.toml``.
188
+
189
+ Returns:
190
+ A full, typed settings object.
191
+
192
+ Raises:
193
+ ConfigError: If a required variable is missing or a file is invalid.
194
+ """
195
+ # override=False -> real environment wins over the file. Safer for secrets.
196
+ load_dotenv(dotenv_path=env_file, override=False)
197
+
198
+ schema_path = Path(_require_env(ENV_SCHEMA_PATH)).expanduser()
199
+ # PROMPT_DIR is optional. If unset, packaged prompt templates are used.
200
+ prompt_dir_raw = _optional_env(ENV_PROMPT_DIR)
201
+ prompt_dir = Path(prompt_dir_raw).expanduser() if prompt_dir_raw else None
202
+
203
+ linking_selection = _selection_for_stage(
204
+ ENV_LINKING_PROVIDER, ENV_LINKING_MODEL, ENV_LINKING_BASE_URL
205
+ )
206
+ sql_selection = _selection_for_stage(
207
+ ENV_SQL_PROVIDER, ENV_SQL_MODEL, ENV_SQL_BASE_URL
208
+ )
209
+
210
+ resolved_config_path = Path(
211
+ config_path
212
+ or _optional_env(ENV_CONFIG_PATH)
213
+ or "config.toml"
214
+ ).expanduser()
215
+ toml_data = _load_toml(resolved_config_path)
216
+
217
+ general_tbl = toml_data.get("general", {})
218
+ general = GeneralSettings(
219
+ sql_dialect=str(general_tbl.get("sql_dialect", GeneralSettings.sql_dialect)),
220
+ log_level=str(general_tbl.get("log_level", GeneralSettings.log_level)),
221
+ strict_json=bool(general_tbl.get("strict_json", GeneralSettings.strict_json)),
222
+ )
223
+
224
+ linking = _stage_from_toml(
225
+ toml_data.get("linking", {}),
226
+ default_template="linking.txt",
227
+ include_max_tables=True,
228
+ )
229
+ generation = _stage_from_toml(
230
+ toml_data.get("generation", {}),
231
+ default_template="generation.txt",
232
+ include_max_tables=False,
233
+ )
234
+
235
+ retry_tbl = toml_data.get("retry", {})
236
+ retry = RetrySettings(
237
+ max_attempts=int(retry_tbl.get("max_attempts", RetrySettings.max_attempts)),
238
+ backoff_seconds=float(retry_tbl.get("backoff_seconds", RetrySettings.backoff_seconds)),
239
+ backoff_factor=float(retry_tbl.get("backoff_factor", RetrySettings.backoff_factor)),
240
+ )
241
+
242
+ return Settings(
243
+ schema_path=schema_path,
244
+ prompt_dir=prompt_dir,
245
+ linking_selection=linking_selection,
246
+ sql_selection=sql_selection,
247
+ general=general,
248
+ linking=linking,
249
+ generation=generation,
250
+ retry=retry,
251
+ )
text2sql/errors.py ADDED
@@ -0,0 +1,43 @@
1
+ """Exceptions used across the library.
2
+
3
+ Everything subclasses ``Text2SQLError``. Catch that to catch all of them.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+
9
+ class Text2SQLError(Exception):
10
+ """Base class for every error this library raises."""
11
+
12
+
13
+ class ConfigError(Text2SQLError):
14
+ """Config is missing or invalid.
15
+
16
+ Example: a required ``.env`` variable is not set, or ``config.toml`` is
17
+ broken, or a provider/model selection is incomplete.
18
+ """
19
+
20
+
21
+ class SchemaError(Text2SQLError):
22
+ """Schema metadata cannot be loaded or is not valid."""
23
+
24
+
25
+ class ProviderError(Text2SQLError):
26
+ """An LLM provider failed to build or a call failed.
27
+
28
+ Wraps SDK/transport errors so callers do not deal with SDK-specific types.
29
+ """
30
+
31
+
32
+ class StructuredOutputError(Text2SQLError):
33
+ """LLM output could not be parsed as the expected JSON.
34
+
35
+ Raised only after all retries are used.
36
+ """
37
+
38
+
39
+ class EmptyLinkingError(Text2SQLError):
40
+ """Stage 1 linked no tables or columns.
41
+
42
+ A valid but empty match means we cannot generate SQL.
43
+ """
@@ -0,0 +1,54 @@
1
+ """Logging helpers.
2
+
3
+ The library uses stdlib :mod:`logging` only, never ``print``, and never logs
4
+ secrets. Call :func:`configure_logging` once with the level from config. Use
5
+ :func:`get_logger` everywhere else.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ _LIBRARY_LOGGER_NAME = "text2sql"
13
+ _configured = False
14
+
15
+
16
+ def configure_logging(level: str = "INFO") -> None:
17
+ """Set up the library logger.
18
+
19
+ Attaches one stream handler to the ``text2sql`` logger, not the root logger,
20
+ so the host application's logging is left alone. Idempotent: later calls
21
+ only change the level.
22
+
23
+ Args:
24
+ level: A standard level name, e.g. ``"DEBUG"``, ``"INFO"``.
25
+ """
26
+ global _configured
27
+ logger = logging.getLogger(_LIBRARY_LOGGER_NAME)
28
+ numeric_level = logging.getLevelName(level.upper())
29
+ if not isinstance(numeric_level, int):
30
+ numeric_level = logging.INFO
31
+ logger.setLevel(numeric_level)
32
+
33
+ if not _configured:
34
+ handler = logging.StreamHandler()
35
+ handler.setFormatter(
36
+ logging.Formatter(
37
+ "%(asctime)s %(levelname)-7s [%(name)s] %(message)s",
38
+ datefmt="%Y-%m-%dT%H:%M:%S",
39
+ )
40
+ )
41
+ logger.addHandler(handler)
42
+ logger.propagate = False
43
+ _configured = True
44
+
45
+
46
+ def get_logger(name: str | None = None) -> logging.Logger:
47
+ """Return a child logger under the ``text2sql`` namespace.
48
+
49
+ Args:
50
+ name: Optional dotted suffix, e.g. ``"pipeline.linking"``.
51
+ """
52
+ if name:
53
+ return logging.getLogger(f"{_LIBRARY_LOGGER_NAME}.{name}")
54
+ return logging.getLogger(_LIBRARY_LOGGER_NAME)
@@ -0,0 +1,9 @@
1
+ """Pipeline stages and the orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .generation import GenerationStage
6
+ from .linking import LinkingStage
7
+ from .pipeline import Text2SQL
8
+
9
+ __all__ = ["Text2SQL", "LinkingStage", "GenerationStage"]
@@ -0,0 +1,110 @@
1
+ """Stage 2 — SQL generation.
2
+
3
+ Takes the reduced schema from Stage 1 and produces a SQL string, plus an
4
+ optional short explanation, in the configured dialect.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from ..config import Settings
12
+ from ..logging_utils import get_logger
13
+ from ..prompts import PromptTemplates
14
+ from ..providers.base import LLMProvider
15
+ from ..schema import Schema, schema_to_prompt
16
+ from ..types import LinkedSchema, LLMResponse, StageMetadata, _Timer
17
+ from .json_utils import extract_json, with_retry
18
+
19
+ logger = get_logger("pipeline.generation")
20
+
21
+ GENERATION_SCHEMA_NAME = "sql_generation"
22
+
23
+ GENERATION_JSON_SCHEMA: dict[str, Any] = {
24
+ "type": "object",
25
+ "additionalProperties": False,
26
+ "properties": {
27
+ "sql": {"type": "string"},
28
+ "explanation": {"type": "string"},
29
+ },
30
+ "required": ["sql", "explanation"],
31
+ }
32
+
33
+
34
+ class GenerationStage:
35
+ """Runs SQL generation (Stage 2)."""
36
+
37
+ def __init__(
38
+ self,
39
+ provider: LLMProvider,
40
+ settings: Settings,
41
+ prompts: PromptTemplates,
42
+ ) -> None:
43
+ self._provider = provider
44
+ self._settings = settings
45
+ self._prompts = prompts
46
+
47
+ def run(
48
+ self,
49
+ request: str,
50
+ linked: LinkedSchema,
51
+ full_schema: Schema,
52
+ ) -> tuple[str, str, StageMetadata]:
53
+ """Generate SQL for ``request`` using the linked subset.
54
+
55
+ Returns ``(sql, explanation, metadata)``.
56
+ """
57
+ stage_cfg = self._settings.generation
58
+
59
+ # Render the reduced schema from the real metadata (types, keys, FKs),
60
+ # limited to the linked tables. Richer than just table names.
61
+ reduced_schema = full_schema.subset(linked.tables)
62
+ reduced_text = schema_to_prompt(reduced_schema) if reduced_schema.tables else "(none)"
63
+
64
+ template = self._prompts.load(stage_cfg.prompt_template)
65
+ system, user = template.render(
66
+ linked_schema=reduced_text,
67
+ request=request,
68
+ dialect=self._settings.general.sql_dialect,
69
+ entities=", ".join(linked.entities) or "(none)",
70
+ filters="; ".join(linked.filters) or "(none)",
71
+ )
72
+
73
+ attempts = {"n": 0}
74
+
75
+ def attempt() -> tuple[str, str, LLMResponse]:
76
+ attempts["n"] += 1
77
+ response = self._provider.complete_json(
78
+ system=system,
79
+ user=user,
80
+ json_schema=GENERATION_JSON_SCHEMA,
81
+ schema_name=GENERATION_SCHEMA_NAME,
82
+ temperature=stage_cfg.temperature,
83
+ top_p=stage_cfg.top_p,
84
+ max_tokens=stage_cfg.max_tokens,
85
+ )
86
+ data = extract_json(response.text, strict=self._settings.general.strict_json)
87
+ sql = str(data.get("sql", "")).strip()
88
+ explanation = str(data.get("explanation", "")).strip()
89
+ if not sql:
90
+ from ..errors import StructuredOutputError
91
+
92
+ raise StructuredOutputError("Generation returned an empty 'sql' field.")
93
+ return sql, explanation, response
94
+
95
+ with _Timer() as timer:
96
+ sql, explanation, response = with_retry(
97
+ attempt,
98
+ retry=self._settings.retry,
99
+ description="SQL generation",
100
+ )
101
+
102
+ metadata = StageMetadata(
103
+ provider=self._provider.name,
104
+ model=self._provider.model,
105
+ usage=response.usage,
106
+ latency_seconds=timer.elapsed,
107
+ attempts=attempts["n"],
108
+ )
109
+ logger.info("Generated SQL (%d chars).", len(sql))
110
+ return sql, explanation, metadata
@@ -0,0 +1,108 @@
1
+ """JSON extraction and retry helpers. Used by both stages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import time
8
+ from typing import Any, Callable, TypeVar
9
+
10
+ from ..config import RetrySettings
11
+ from ..errors import ProviderError, StructuredOutputError
12
+ from ..logging_utils import get_logger
13
+
14
+ logger = get_logger("pipeline.json")
15
+
16
+ _FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ def extract_json(text: str, *, strict: bool) -> dict[str, Any]:
22
+ """Parse a JSON object from raw model text.
23
+
24
+ Args:
25
+ text: The raw response body.
26
+ strict: If ``True``, only try a direct ``json.loads``. If ``False``,
27
+ also strip markdown fences and grab the first ``{...}`` block.
28
+
29
+ Raises:
30
+ StructuredOutputError: If no JSON object can be parsed.
31
+ """
32
+ candidate = text.strip()
33
+ try:
34
+ parsed = json.loads(candidate)
35
+ if isinstance(parsed, dict):
36
+ return parsed
37
+ raise StructuredOutputError("Expected a JSON object, got something else.")
38
+ except json.JSONDecodeError:
39
+ if strict:
40
+ raise StructuredOutputError(
41
+ "Response was not valid JSON (strict_json is on)."
42
+ )
43
+
44
+ # Lenient path: strip fences, then take the first {...} block.
45
+ fence = _FENCE_RE.search(candidate)
46
+ if fence:
47
+ candidate = fence.group(1).strip()
48
+ try:
49
+ parsed = json.loads(candidate)
50
+ if isinstance(parsed, dict):
51
+ return parsed
52
+ except json.JSONDecodeError:
53
+ pass
54
+
55
+ start = candidate.find("{")
56
+ end = candidate.rfind("}")
57
+ if start != -1 and end != -1 and end > start:
58
+ try:
59
+ parsed = json.loads(candidate[start : end + 1])
60
+ if isinstance(parsed, dict):
61
+ return parsed
62
+ except json.JSONDecodeError:
63
+ pass
64
+
65
+ raise StructuredOutputError("Could not find a JSON object in the response.")
66
+
67
+
68
+ def with_retry(
69
+ func: Callable[[], T],
70
+ *,
71
+ retry: RetrySettings,
72
+ description: str,
73
+ ) -> T:
74
+ """Run ``func`` with retry and backoff.
75
+
76
+ Retries on :class:`StructuredOutputError` and transient
77
+ :class:`ProviderError`. Re-raises the last error when attempts run out.
78
+
79
+ Args:
80
+ func: Zero-arg callable. One attempt.
81
+ retry: Retry config.
82
+ description: Label used in log messages.
83
+ """
84
+ attempts = max(1, retry.max_attempts)
85
+ delay = retry.backoff_seconds
86
+ last_exc: Exception | None = None
87
+
88
+ for attempt in range(1, attempts + 1):
89
+ try:
90
+ return func()
91
+ except (StructuredOutputError, ProviderError) as exc:
92
+ last_exc = exc
93
+ if attempt >= attempts:
94
+ break
95
+ logger.warning(
96
+ "%s failed on attempt %d/%d (%s). Retrying in %.1fs.",
97
+ description,
98
+ attempt,
99
+ attempts,
100
+ type(exc).__name__,
101
+ delay,
102
+ )
103
+ if delay > 0:
104
+ time.sleep(delay)
105
+ delay *= retry.backoff_factor
106
+
107
+ assert last_exc is not None
108
+ raise last_exc