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.
@@ -0,0 +1,206 @@
1
+ """Stage 1 — schema linking.
2
+
3
+ Reduces the full schema to the part relevant to the request. Uses an
4
+ intermediate LLM with forced structured (JSON) output.
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 (
17
+ LinkedColumn,
18
+ LinkedJoin,
19
+ LinkedSchema,
20
+ LLMResponse,
21
+ StageMetadata,
22
+ _Timer,
23
+ )
24
+ from .json_utils import extract_json, with_retry
25
+
26
+ logger = get_logger("pipeline.linking")
27
+
28
+ #: Name for the structured-output tool / response_format schema.
29
+ LINKING_SCHEMA_NAME = "linked_schema"
30
+
31
+ #: JSON schema forced on the Stage 1 output.
32
+ LINKING_JSON_SCHEMA: dict[str, Any] = {
33
+ "type": "object",
34
+ "additionalProperties": False,
35
+ "properties": {
36
+ "tables": {"type": "array", "items": {"type": "string"}},
37
+ "columns": {
38
+ "type": "array",
39
+ "items": {
40
+ "type": "object",
41
+ "additionalProperties": False,
42
+ "properties": {
43
+ "table": {"type": "string"},
44
+ "column": {"type": "string"},
45
+ "reason": {"type": "string"},
46
+ },
47
+ "required": ["table", "column", "reason"],
48
+ },
49
+ },
50
+ "joins": {
51
+ "type": "array",
52
+ "items": {
53
+ "type": "object",
54
+ "additionalProperties": False,
55
+ "properties": {
56
+ "left_table": {"type": "string"},
57
+ "left_column": {"type": "string"},
58
+ "right_table": {"type": "string"},
59
+ "right_column": {"type": "string"},
60
+ },
61
+ "required": [
62
+ "left_table",
63
+ "left_column",
64
+ "right_table",
65
+ "right_column",
66
+ ],
67
+ },
68
+ },
69
+ "entities": {"type": "array", "items": {"type": "string"}},
70
+ "filters": {"type": "array", "items": {"type": "string"}},
71
+ "rationale": {"type": "string"},
72
+ },
73
+ "required": ["tables", "columns", "joins", "entities", "filters", "rationale"],
74
+ }
75
+
76
+
77
+ class LinkingStage:
78
+ """Runs schema linking (Stage 1)."""
79
+
80
+ def __init__(
81
+ self,
82
+ provider: LLMProvider,
83
+ settings: Settings,
84
+ prompts: PromptTemplates,
85
+ ) -> None:
86
+ self._provider = provider
87
+ self._settings = settings
88
+ self._prompts = prompts
89
+
90
+ def run(self, request: str, schema: Schema) -> tuple[LinkedSchema, StageMetadata]:
91
+ """Link the request against ``schema``.
92
+
93
+ Returns the reduced schema and the stage metadata.
94
+ """
95
+ stage_cfg = self._settings.linking
96
+ template = self._prompts.load(stage_cfg.prompt_template)
97
+ system, user = template.render(
98
+ schema=schema_to_prompt(schema),
99
+ request=request,
100
+ dialect=self._settings.general.sql_dialect,
101
+ max_tables=stage_cfg.max_tables,
102
+ )
103
+
104
+ attempts = {"n": 0}
105
+
106
+ def attempt() -> tuple[LinkedSchema, LLMResponse]:
107
+ attempts["n"] += 1
108
+ response = self._provider.complete_json(
109
+ system=system,
110
+ user=user,
111
+ json_schema=LINKING_JSON_SCHEMA,
112
+ schema_name=LINKING_SCHEMA_NAME,
113
+ temperature=stage_cfg.temperature,
114
+ top_p=stage_cfg.top_p,
115
+ max_tokens=stage_cfg.max_tokens,
116
+ )
117
+ data = extract_json(response.text, strict=self._settings.general.strict_json)
118
+ linked = self._to_linked_schema(data, schema, stage_cfg.max_tables)
119
+ return linked, response
120
+
121
+ with _Timer() as timer:
122
+ linked, response = with_retry(
123
+ attempt,
124
+ retry=self._settings.retry,
125
+ description="Schema linking",
126
+ )
127
+
128
+ metadata = StageMetadata(
129
+ provider=self._provider.name,
130
+ model=self._provider.model,
131
+ usage=response.usage,
132
+ latency_seconds=timer.elapsed,
133
+ attempts=attempts["n"],
134
+ )
135
+ logger.info(
136
+ "Linking picked %d table(s): %s",
137
+ len(linked.tables),
138
+ ", ".join(linked.tables) or "(none)",
139
+ )
140
+ return linked, metadata
141
+
142
+ @staticmethod
143
+ def _to_linked_schema(
144
+ data: dict[str, Any],
145
+ schema: Schema,
146
+ max_tables: int,
147
+ ) -> LinkedSchema:
148
+ """Check the parsed JSON against the real schema and build a LinkedSchema.
149
+
150
+ Tables/columns not in the source schema are dropped. This guards against
151
+ hallucinated names. Tables are capped at ``max_tables``.
152
+ """
153
+ valid_tables: list[str] = []
154
+ for name in data.get("tables", []):
155
+ tbl = schema.table(str(name))
156
+ if tbl is not None and tbl.name not in valid_tables:
157
+ valid_tables.append(tbl.name)
158
+ elif tbl is None:
159
+ logger.debug("Dropping hallucinated table '%s'.", name)
160
+ valid_tables = valid_tables[:max_tables]
161
+ valid_set = {t.lower() for t in valid_tables}
162
+
163
+ columns: list[LinkedColumn] = []
164
+ for col in data.get("columns", []):
165
+ if not isinstance(col, dict):
166
+ continue
167
+ table_name = str(col.get("table", ""))
168
+ column_name = str(col.get("column", ""))
169
+ tbl = schema.table(table_name)
170
+ if tbl is None or tbl.name.lower() not in valid_set:
171
+ continue
172
+ if tbl.column(column_name) is None:
173
+ logger.debug("Dropping hallucinated column '%s.%s'.", table_name, column_name)
174
+ continue
175
+ columns.append(
176
+ LinkedColumn(
177
+ table=tbl.name,
178
+ column=column_name,
179
+ reason=str(col.get("reason", "")),
180
+ )
181
+ )
182
+
183
+ joins: list[LinkedJoin] = []
184
+ for j in data.get("joins", []):
185
+ if not isinstance(j, dict):
186
+ continue
187
+ joins.append(
188
+ LinkedJoin(
189
+ left_table=str(j.get("left_table", "")),
190
+ left_column=str(j.get("left_column", "")),
191
+ right_table=str(j.get("right_table", "")),
192
+ right_column=str(j.get("right_column", "")),
193
+ )
194
+ )
195
+
196
+ entities = [str(e) for e in data.get("entities", []) if str(e).strip()]
197
+ filters = [str(f) for f in data.get("filters", []) if str(f).strip()]
198
+
199
+ return LinkedSchema(
200
+ tables=valid_tables,
201
+ columns=columns,
202
+ joins=joins,
203
+ entities=entities,
204
+ filters=filters,
205
+ rationale=str(data.get("rationale", "")),
206
+ )
@@ -0,0 +1,156 @@
1
+ """Public API: the :class:`Text2SQL` orchestrator.
2
+
3
+ Combines Stage 1 (linking) and Stage 2 (generation) in one ``run`` call.
4
+ Everything a caller may want to swap — settings, schema provider, prompt
5
+ templates, and the per-stage LLM providers — is injectable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..config import Settings, load_settings
11
+ from ..errors import EmptyLinkingError, SchemaError
12
+ from ..logging_utils import configure_logging, get_logger
13
+ from ..prompts import PromptTemplates
14
+ from ..providers.base import LLMProvider
15
+ from ..providers.registry import build_provider
16
+ from ..schema import FileSchemaProvider, Schema
17
+ from ..schema.provider import SchemaProvider
18
+ from ..types import RunMetadata, Text2SQLResult, _Timer
19
+ from .generation import GenerationStage
20
+ from .linking import LinkingStage
21
+
22
+ logger = get_logger("pipeline")
23
+
24
+
25
+ class Text2SQL:
26
+ """Two-stage, schema-agnostic Text-to-SQL engine.
27
+
28
+ Use :meth:`from_config` for the config-driven path, or the constructor
29
+ directly to inject custom parts.
30
+
31
+ Args:
32
+ settings: Full settings.
33
+ schema_provider: Gives the schema metadata.
34
+ prompts: Prompt template loader.
35
+ linking_provider: LLM provider for Stage 1.
36
+ generation_provider: LLM provider for Stage 2.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ settings: Settings,
42
+ schema_provider: SchemaProvider,
43
+ prompts: PromptTemplates,
44
+ linking_provider: LLMProvider,
45
+ generation_provider: LLMProvider,
46
+ ) -> None:
47
+ self._settings = settings
48
+ self._schema_provider = schema_provider
49
+ self._prompts = prompts
50
+ self._linking = LinkingStage(linking_provider, settings, prompts)
51
+ self._generation = GenerationStage(generation_provider, settings, prompts)
52
+
53
+ # Load the schema once, at construction, so problems show up early.
54
+ self._schema: Schema = schema_provider.load()
55
+ if not self._schema.tables:
56
+ raise SchemaError("Loaded schema has no tables.")
57
+
58
+ # Construction
59
+ @classmethod
60
+ def from_config(
61
+ cls,
62
+ *,
63
+ env_file: str | None = None,
64
+ config_path: str | None = None,
65
+ schema_provider: SchemaProvider | None = None,
66
+ prompts: PromptTemplates | None = None,
67
+ linking_provider: LLMProvider | None = None,
68
+ generation_provider: LLMProvider | None = None,
69
+ ) -> "Text2SQL":
70
+ """Build an engine from ``.env`` + ``config.toml``.
71
+
72
+ Any part can be overridden with a keyword argument to inject a custom
73
+ one. Anything left as ``None`` is built from config.
74
+
75
+ Raises:
76
+ ConfigError: If config is missing or invalid.
77
+ SchemaError: If the schema cannot be loaded.
78
+ """
79
+ settings = load_settings(env_file=env_file, config_path=config_path)
80
+ configure_logging(settings.general.log_level)
81
+
82
+ schema_provider = schema_provider or FileSchemaProvider(settings.schema_path)
83
+ # settings.prompt_dir may be None -> PromptTemplates uses packaged files.
84
+ prompts = prompts or PromptTemplates(settings.prompt_dir)
85
+
86
+ linking_provider = linking_provider or build_provider(
87
+ settings.linking_selection, timeout=settings.linking.timeout_seconds
88
+ )
89
+ generation_provider = generation_provider or build_provider(
90
+ settings.sql_selection, timeout=settings.generation.timeout_seconds
91
+ )
92
+
93
+ logger.debug(
94
+ "Text2SQL ready: linking=%s/%s generation=%s/%s dialect=%s",
95
+ linking_provider.name,
96
+ linking_provider.model,
97
+ generation_provider.name,
98
+ generation_provider.model,
99
+ settings.general.sql_dialect,
100
+ )
101
+ return cls(
102
+ settings=settings,
103
+ schema_provider=schema_provider,
104
+ prompts=prompts,
105
+ linking_provider=linking_provider,
106
+ generation_provider=generation_provider,
107
+ )
108
+
109
+ # Execution
110
+ def run(self, request: str) -> Text2SQLResult:
111
+ """Run the full pipeline for a natural-language ``request``.
112
+
113
+ Returns:
114
+ A :class:`~text2sql.types.Text2SQLResult` with the SQL string, the
115
+ Stage 1 linked schema, and run metadata.
116
+
117
+ Raises:
118
+ EmptyLinkingError: If Stage 1 links no tables/columns.
119
+ StructuredOutputError: If a stage's output cannot be parsed after
120
+ all retries.
121
+ ProviderError: If an LLM call fails.
122
+ """
123
+ if not request or not request.strip():
124
+ raise ValueError("request must be a non-empty string.")
125
+
126
+ logger.info("Running Text2SQL for request: %r", request)
127
+ with _Timer() as total_timer:
128
+ linked, linking_meta = self._linking.run(request, self._schema)
129
+
130
+ if linked.is_empty():
131
+ raise EmptyLinkingError(
132
+ "Schema linking found no relevant table or column for the "
133
+ "request. The request may not match the loaded schema."
134
+ )
135
+
136
+ sql, explanation, generation_meta = self._generation.run(
137
+ request, linked, self._schema
138
+ )
139
+
140
+ metadata = RunMetadata(
141
+ linking=linking_meta,
142
+ generation=generation_meta,
143
+ total_latency_seconds=total_timer.elapsed,
144
+ )
145
+ return Text2SQLResult(
146
+ sql=sql,
147
+ explanation=explanation,
148
+ linked_schema=linked,
149
+ metadata=metadata,
150
+ request=request,
151
+ )
152
+
153
+ @property
154
+ def schema(self) -> Schema:
155
+ """The loaded schema. Read-only access for callers."""
156
+ return self._schema
@@ -0,0 +1,96 @@
1
+ """Prompt template loading.
2
+
3
+ Templates are editable ``.txt`` files, kept outside the code. Each file has a
4
+ ``SYSTEM:`` section and a ``USER:`` section. Both support ``str.format``-style
5
+ ``{placeholder}`` substitution.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib import resources
11
+ from pathlib import Path
12
+
13
+ from ..errors import ConfigError
14
+
15
+ #: Package that holds the built-in prompt templates.
16
+ _PACKAGE = "text2sql.prompts"
17
+
18
+ _SYSTEM_MARKER = "SYSTEM:"
19
+ _USER_MARKER = "USER:"
20
+
21
+
22
+ class PromptTemplate:
23
+ """A parsed template with a system and a user section."""
24
+
25
+ def __init__(self, system: str, user: str) -> None:
26
+ self.system = system
27
+ self.user = user
28
+
29
+ def render(self, **kwargs: object) -> tuple[str, str]:
30
+ """Return ``(system, user)`` with placeholders filled.
31
+
32
+ Raises:
33
+ ConfigError: If the template uses a placeholder that is not given.
34
+ """
35
+ try:
36
+ return (
37
+ self.system.format(**kwargs),
38
+ self.user.format(**kwargs),
39
+ )
40
+ except KeyError as exc:
41
+ raise ConfigError(
42
+ f"Prompt template uses an unknown placeholder {exc}."
43
+ ) from exc
44
+
45
+
46
+ class PromptTemplates:
47
+ """Loads named templates.
48
+
49
+ This is the DI seam for prompts: pass a different directory (or subclass) to
50
+ swap templates without touching code.
51
+
52
+ Args:
53
+ directory: Directory with the ``.txt`` files. When ``None`` (default),
54
+ the templates packaged inside ``text2sql.prompts`` are used, so the
55
+ library works when installed as a package, from any working
56
+ directory.
57
+ """
58
+
59
+ def __init__(self, directory: str | Path | None = None) -> None:
60
+ self._dir = Path(directory).expanduser() if directory is not None else None
61
+
62
+ def load(self, filename: str) -> PromptTemplate:
63
+ """Load and parse a template by name.
64
+
65
+ Raises:
66
+ ConfigError: If the template is missing or lacks the two sections.
67
+ """
68
+ if self._dir is not None:
69
+ path = self._dir / filename
70
+ if not path.is_file():
71
+ raise ConfigError(f"Prompt template not found: '{path}'.")
72
+ return self._parse(path.read_text(encoding="utf-8"), str(path))
73
+
74
+ # Fall back to the templates packaged with the library.
75
+ try:
76
+ resource = resources.files(_PACKAGE).joinpath(filename)
77
+ text = resource.read_text(encoding="utf-8")
78
+ except (FileNotFoundError, ModuleNotFoundError) as exc:
79
+ raise ConfigError(
80
+ f"Packaged prompt template '{filename}' not found in {_PACKAGE}."
81
+ ) from exc
82
+ return self._parse(text, f"{_PACKAGE}/{filename}")
83
+
84
+ @staticmethod
85
+ def _parse(text: str, path: str) -> PromptTemplate:
86
+ if _SYSTEM_MARKER not in text or _USER_MARKER not in text:
87
+ raise ConfigError(
88
+ f"Prompt template '{path}' must have a '{_SYSTEM_MARKER}' and a "
89
+ f"'{_USER_MARKER}' section."
90
+ )
91
+ _, _, remainder = text.partition(_SYSTEM_MARKER)
92
+ system_part, _, user_part = remainder.partition(_USER_MARKER)
93
+ return PromptTemplate(system=system_part.strip(), user=user_part.strip())
94
+
95
+
96
+ __all__ = ["PromptTemplate", "PromptTemplates"]
@@ -0,0 +1,28 @@
1
+ SYSTEM:
2
+ You are an expert SQL generator. You get a user request and a reduced schema
3
+ (only the relevant tables/columns/joins picked by an upstream linking step).
4
+ Produce one correct SQL query in the target dialect.
5
+
6
+ Rules:
7
+ - Use only the tables and columns in the reduced schema.
8
+ - Write idiomatic SQL for the target dialect: {dialect}.
9
+ - Prefer explicit JOINs using the given relations.
10
+ - Do not run the query. Only produce it.
11
+ - Return the answer strictly as the requested JSON object with two fields:
12
+ "sql" (the query) and "explanation" (one or two sentences).
13
+
14
+ USER:
15
+ Target SQL dialect: {dialect}
16
+
17
+ Reduced schema (relevant subset):
18
+ --------------------------------
19
+ {linked_schema}
20
+ --------------------------------
21
+
22
+ Detected entities: {entities}
23
+ Filter hints: {filters}
24
+
25
+ User request:
26
+ "{request}"
27
+
28
+ Write the SQL query that answers the request.
@@ -0,0 +1,28 @@
1
+ SYSTEM:
2
+ You are a schema-linking assistant for a Text-to-SQL system. You get a user
3
+ request and the full metadata of a database schema. Your job: find the subset of
4
+ the schema that is relevant to the request.
5
+
6
+ Rules:
7
+ - Pick only the tables and columns that are actually needed.
8
+ - Propose the join paths (from the foreign keys) needed to connect them.
9
+ - Extract the entities and values named in the request.
10
+ - Note any filter conditions the request implies.
11
+ - Return at most {max_tables} tables.
12
+ - Never invent a table or column that is not in the metadata.
13
+ - Give a short rationale for your choice.
14
+
15
+ Return the answer strictly as the requested JSON object.
16
+
17
+ USER:
18
+ Database schema metadata:
19
+ --------------------------------
20
+ {schema}
21
+ --------------------------------
22
+
23
+ Target SQL dialect: {dialect}
24
+
25
+ User request:
26
+ "{request}"
27
+
28
+ Find the relevant tables, columns, joins, entities, and filters.
@@ -0,0 +1,19 @@
1
+ """LLM client layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import (
6
+ LLMProvider,
7
+ get_provider_class,
8
+ register_provider,
9
+ registered_providers,
10
+ )
11
+ from .registry import build_provider
12
+
13
+ __all__ = [
14
+ "LLMProvider",
15
+ "register_provider",
16
+ "registered_providers",
17
+ "get_provider_class",
18
+ "build_provider",
19
+ ]
@@ -0,0 +1,123 @@
1
+ """Anthropic provider.
2
+
3
+ Uses the Messages API. Structured output is forced with tool-calling: one tool
4
+ whose ``input_schema`` is the JSON schema, plus ``tool_choice`` to force it. The
5
+ ``anthropic`` SDK is imported lazily.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ from ..errors import ProviderError
14
+ from ..logging_utils import get_logger
15
+ from ..types import LLMResponse, TokenUsage
16
+ from .base import LLMProvider, register_provider
17
+
18
+ logger = get_logger("providers.anthropic")
19
+
20
+
21
+ @register_provider("anthropic")
22
+ class AnthropicProvider(LLMProvider):
23
+ """Provider backed by the Anthropic Messages API."""
24
+
25
+ def _client(self) -> Any:
26
+ try:
27
+ from anthropic import Anthropic
28
+ except ImportError as exc: # pragma: no cover - dependency guard
29
+ raise ProviderError(
30
+ "The 'anthropic' package is required for the Anthropic provider. "
31
+ "Install it with 'pip install anthropic'."
32
+ ) from exc
33
+ kwargs: dict[str, Any] = {"api_key": self._api_key, "timeout": self._timeout}
34
+ if self._base_url:
35
+ kwargs["base_url"] = self._base_url
36
+ return Anthropic(**kwargs)
37
+
38
+ @staticmethod
39
+ def _usage(response: Any) -> TokenUsage:
40
+ usage = getattr(response, "usage", None)
41
+ if usage is None:
42
+ return TokenUsage()
43
+ prompt = getattr(usage, "input_tokens", 0) or 0
44
+ completion = getattr(usage, "output_tokens", 0) or 0
45
+ return TokenUsage(
46
+ prompt_tokens=prompt,
47
+ completion_tokens=completion,
48
+ total_tokens=prompt + completion,
49
+ )
50
+
51
+ def complete(
52
+ self,
53
+ *,
54
+ system: str,
55
+ user: str,
56
+ temperature: float,
57
+ top_p: float,
58
+ max_tokens: int,
59
+ ) -> LLMResponse:
60
+ client = self._client()
61
+ logger.debug("Anthropic completion request (model=%s)", self.model)
62
+ try:
63
+ response = client.messages.create(
64
+ model=self.model,
65
+ system=system,
66
+ messages=[{"role": "user", "content": user}],
67
+ temperature=temperature,
68
+ top_p=top_p,
69
+ max_tokens=max_tokens,
70
+ )
71
+ except Exception as exc: # noqa: BLE001
72
+ raise ProviderError(f"Anthropic request failed: {exc}") from exc
73
+
74
+ text = "".join(
75
+ block.text for block in response.content if getattr(block, "type", "") == "text"
76
+ )
77
+ return LLMResponse(text=text, usage=self._usage(response), model=self.model, raw=response)
78
+
79
+ def complete_json(
80
+ self,
81
+ *,
82
+ system: str,
83
+ user: str,
84
+ json_schema: dict[str, Any],
85
+ schema_name: str,
86
+ temperature: float,
87
+ top_p: float,
88
+ max_tokens: int,
89
+ ) -> LLMResponse:
90
+ client = self._client()
91
+ tool = {
92
+ "name": schema_name,
93
+ "description": "Return the structured result with this tool.",
94
+ "input_schema": json_schema,
95
+ }
96
+ logger.debug("Anthropic structured (tool-use) request (model=%s)", self.model)
97
+ try:
98
+ response = client.messages.create(
99
+ model=self.model,
100
+ system=system,
101
+ messages=[{"role": "user", "content": user}],
102
+ temperature=temperature,
103
+ top_p=top_p,
104
+ max_tokens=max_tokens,
105
+ tools=[tool],
106
+ tool_choice={"type": "tool", "name": schema_name},
107
+ )
108
+ except Exception as exc: # noqa: BLE001
109
+ raise ProviderError(f"Anthropic structured request failed: {exc}") from exc
110
+
111
+ # Take the tool-use block's input as the JSON payload.
112
+ for block in response.content:
113
+ if getattr(block, "type", "") == "tool_use":
114
+ text = json.dumps(block.input)
115
+ return LLMResponse(
116
+ text=text, usage=self._usage(response), model=self.model, raw=response
117
+ )
118
+
119
+ # No tool call came back. Return whatever text there is, for the parser.
120
+ text = "".join(
121
+ block.text for block in response.content if getattr(block, "type", "") == "text"
122
+ )
123
+ return LLMResponse(text=text, usage=self._usage(response), model=self.model, raw=response)