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 +67 -0
- text2sql/config.py +251 -0
- text2sql/errors.py +43 -0
- text2sql/logging_utils.py +54 -0
- text2sql/pipeline/__init__.py +9 -0
- text2sql/pipeline/generation.py +110 -0
- text2sql/pipeline/json_utils.py +108 -0
- text2sql/pipeline/linking.py +206 -0
- text2sql/pipeline/pipeline.py +156 -0
- text2sql/prompts/__init__.py +96 -0
- text2sql/prompts/generation.txt +28 -0
- text2sql/prompts/linking.txt +28 -0
- text2sql/providers/__init__.py +19 -0
- text2sql/providers/anthropic_provider.py +123 -0
- text2sql/providers/base.py +131 -0
- text2sql/providers/openai_provider.py +125 -0
- text2sql/providers/registry.py +38 -0
- text2sql/py.typed +0 -0
- text2sql/schema/__init__.py +18 -0
- text2sql/schema/file_provider.py +140 -0
- text2sql/schema/models.py +84 -0
- text2sql/schema/provider.py +30 -0
- text2sql/schema/serialize.py +55 -0
- text2sql/types.py +138 -0
- text2sql_engine-0.1.0.dist-info/METADATA +304 -0
- text2sql_engine-0.1.0.dist-info/RECORD +29 -0
- text2sql_engine-0.1.0.dist-info/WHEEL +5 -0
- text2sql_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- text2sql_engine-0.1.0.dist-info/top_level.txt +1 -0
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,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
|