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
|
@@ -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)
|