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,131 @@
|
|
|
1
|
+
"""Provider-agnostic LLM client layer.
|
|
2
|
+
|
|
3
|
+
An :class:`LLMProvider` turns a prompt into text. Two entry points:
|
|
4
|
+
|
|
5
|
+
* :meth:`complete` — free-form text. Used by Stage 2.
|
|
6
|
+
* :meth:`complete_json` — output forced to a JSON object that matches a given
|
|
7
|
+
JSON schema. Used by Stage 1. Providers with native structured output
|
|
8
|
+
(``response_format`` / tool-calling) should use it. The base class gives a
|
|
9
|
+
plain-text fallback for the rest.
|
|
10
|
+
|
|
11
|
+
Adding a provider is one subclass plus the ``@register_provider`` decorator.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from typing import Any, Callable
|
|
19
|
+
|
|
20
|
+
from ..logging_utils import get_logger
|
|
21
|
+
from ..types import LLMResponse
|
|
22
|
+
|
|
23
|
+
logger = get_logger("providers")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LLMProvider(ABC):
|
|
27
|
+
"""Base class for all providers.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
model: Model id for this instance.
|
|
31
|
+
api_key: Secret key. Never logged.
|
|
32
|
+
base_url: Optional endpoint override, for OpenAI-compatible gateways.
|
|
33
|
+
timeout: Request timeout in seconds.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
#: Registry name. Set by ``@register_provider``.
|
|
37
|
+
name: str = ""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
model: str,
|
|
42
|
+
*,
|
|
43
|
+
api_key: str | None = None,
|
|
44
|
+
base_url: str | None = None,
|
|
45
|
+
timeout: float = 60.0,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.model = model
|
|
48
|
+
self._api_key = api_key
|
|
49
|
+
self._base_url = base_url
|
|
50
|
+
self._timeout = timeout
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def complete(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
system: str,
|
|
57
|
+
user: str,
|
|
58
|
+
temperature: float,
|
|
59
|
+
top_p: float,
|
|
60
|
+
max_tokens: int,
|
|
61
|
+
) -> LLMResponse:
|
|
62
|
+
"""Return a free-form text completion."""
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
def complete_json(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
system: str,
|
|
69
|
+
user: str,
|
|
70
|
+
json_schema: dict[str, Any],
|
|
71
|
+
schema_name: str,
|
|
72
|
+
temperature: float,
|
|
73
|
+
top_p: float,
|
|
74
|
+
max_tokens: int,
|
|
75
|
+
) -> LLMResponse:
|
|
76
|
+
"""Return a completion forced to a JSON object.
|
|
77
|
+
|
|
78
|
+
Default: append the JSON schema to the prompt and rely on text parsing.
|
|
79
|
+
Providers with native structured output should override this for a
|
|
80
|
+
stronger guarantee.
|
|
81
|
+
"""
|
|
82
|
+
augmented_user = (
|
|
83
|
+
f"{user}\n\n"
|
|
84
|
+
f"Respond with a single JSON object that matches this JSON schema. "
|
|
85
|
+
f"Do not wrap it in markdown fences and do not add any comment.\n"
|
|
86
|
+
f"JSON schema:\n{json.dumps(json_schema)}"
|
|
87
|
+
)
|
|
88
|
+
return self.complete(
|
|
89
|
+
system=system,
|
|
90
|
+
user=augmented_user,
|
|
91
|
+
temperature=temperature,
|
|
92
|
+
top_p=top_p,
|
|
93
|
+
max_tokens=max_tokens,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Registry / factory.
|
|
98
|
+
_REGISTRY: dict[str, type[LLMProvider]] = {}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def register_provider(name: str) -> Callable[[type[LLMProvider]], type[LLMProvider]]:
|
|
102
|
+
"""Class decorator. Registers a provider under ``name``."""
|
|
103
|
+
|
|
104
|
+
def decorator(cls: type[LLMProvider]) -> type[LLMProvider]:
|
|
105
|
+
cls.name = name
|
|
106
|
+
_REGISTRY[name.lower()] = cls
|
|
107
|
+
return cls
|
|
108
|
+
|
|
109
|
+
return decorator
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def registered_providers() -> list[str]:
|
|
113
|
+
"""Return the registered provider names, sorted."""
|
|
114
|
+
return sorted(_REGISTRY)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_provider_class(name: str) -> type[LLMProvider]:
|
|
118
|
+
"""Look up a provider class by name.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
ProviderError: If no provider is registered under ``name``.
|
|
122
|
+
"""
|
|
123
|
+
from ..errors import ProviderError
|
|
124
|
+
|
|
125
|
+
cls = _REGISTRY.get(name.lower())
|
|
126
|
+
if cls is None:
|
|
127
|
+
raise ProviderError(
|
|
128
|
+
f"Unknown provider '{name}'. Registered providers: "
|
|
129
|
+
f"{', '.join(registered_providers()) or '(none)'}."
|
|
130
|
+
)
|
|
131
|
+
return cls
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""OpenAI-compatible provider.
|
|
2
|
+
|
|
3
|
+
Works with the OpenAI API and any OpenAI-compatible gateway (via ``base_url``).
|
|
4
|
+
Uses ``response_format`` JSON-schema mode for structured output when available.
|
|
5
|
+
The ``openai`` SDK is imported lazily, so the library imports without it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..errors import ProviderError
|
|
13
|
+
from ..logging_utils import get_logger
|
|
14
|
+
from ..types import LLMResponse, TokenUsage
|
|
15
|
+
from .base import LLMProvider, register_provider
|
|
16
|
+
|
|
17
|
+
logger = get_logger("providers.openai")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@register_provider("openai")
|
|
21
|
+
class OpenAIProvider(LLMProvider):
|
|
22
|
+
"""Provider backed by the OpenAI Chat Completions API."""
|
|
23
|
+
|
|
24
|
+
def _client(self) -> Any:
|
|
25
|
+
try:
|
|
26
|
+
from openai import OpenAI
|
|
27
|
+
except ImportError as exc: # pragma: no cover - dependency guard
|
|
28
|
+
raise ProviderError(
|
|
29
|
+
"The 'openai' package is required for the OpenAI provider. "
|
|
30
|
+
"Install it with 'pip install openai'."
|
|
31
|
+
) from exc
|
|
32
|
+
# base_url=None lets the SDK use its default endpoint.
|
|
33
|
+
return OpenAI(api_key=self._api_key, base_url=self._base_url, timeout=self._timeout)
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def _usage(response: Any) -> TokenUsage:
|
|
37
|
+
usage = getattr(response, "usage", None)
|
|
38
|
+
if usage is None:
|
|
39
|
+
return TokenUsage()
|
|
40
|
+
return TokenUsage(
|
|
41
|
+
prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0,
|
|
42
|
+
completion_tokens=getattr(usage, "completion_tokens", 0) or 0,
|
|
43
|
+
total_tokens=getattr(usage, "total_tokens", 0) or 0,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def complete(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
system: str,
|
|
50
|
+
user: str,
|
|
51
|
+
temperature: float,
|
|
52
|
+
top_p: float,
|
|
53
|
+
max_tokens: int,
|
|
54
|
+
) -> LLMResponse:
|
|
55
|
+
client = self._client()
|
|
56
|
+
logger.debug("OpenAI completion request (model=%s)", self.model)
|
|
57
|
+
try:
|
|
58
|
+
response = client.chat.completions.create(
|
|
59
|
+
model=self.model,
|
|
60
|
+
messages=[
|
|
61
|
+
{"role": "system", "content": system},
|
|
62
|
+
{"role": "user", "content": user},
|
|
63
|
+
],
|
|
64
|
+
temperature=temperature,
|
|
65
|
+
top_p=top_p,
|
|
66
|
+
max_tokens=max_tokens,
|
|
67
|
+
)
|
|
68
|
+
except Exception as exc: # noqa: BLE001 - normalize SDK errors
|
|
69
|
+
raise ProviderError(f"OpenAI request failed: {exc}") from exc
|
|
70
|
+
|
|
71
|
+
text = response.choices[0].message.content or ""
|
|
72
|
+
return LLMResponse(text=text, usage=self._usage(response), model=self.model, raw=response)
|
|
73
|
+
|
|
74
|
+
def complete_json(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
system: str,
|
|
78
|
+
user: str,
|
|
79
|
+
json_schema: dict[str, Any],
|
|
80
|
+
schema_name: str,
|
|
81
|
+
temperature: float,
|
|
82
|
+
top_p: float,
|
|
83
|
+
max_tokens: int,
|
|
84
|
+
) -> LLMResponse:
|
|
85
|
+
client = self._client()
|
|
86
|
+
response_format = {
|
|
87
|
+
"type": "json_schema",
|
|
88
|
+
"json_schema": {
|
|
89
|
+
"name": schema_name,
|
|
90
|
+
"schema": json_schema,
|
|
91
|
+
"strict": True,
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
logger.debug("OpenAI structured request (model=%s)", self.model)
|
|
95
|
+
try:
|
|
96
|
+
response = client.chat.completions.create(
|
|
97
|
+
model=self.model,
|
|
98
|
+
messages=[
|
|
99
|
+
{"role": "system", "content": system},
|
|
100
|
+
{"role": "user", "content": user},
|
|
101
|
+
],
|
|
102
|
+
temperature=temperature,
|
|
103
|
+
top_p=top_p,
|
|
104
|
+
max_tokens=max_tokens,
|
|
105
|
+
response_format=response_format,
|
|
106
|
+
)
|
|
107
|
+
except Exception as exc: # noqa: BLE001
|
|
108
|
+
# Some OpenAI-compatible gateways do not support json_schema mode.
|
|
109
|
+
# Fall back to schema-in-prompt + text parsing.
|
|
110
|
+
logger.warning(
|
|
111
|
+
"OpenAI structured output failed (%s). Falling back to text parsing.",
|
|
112
|
+
exc,
|
|
113
|
+
)
|
|
114
|
+
return super().complete_json(
|
|
115
|
+
system=system,
|
|
116
|
+
user=user,
|
|
117
|
+
json_schema=json_schema,
|
|
118
|
+
schema_name=schema_name,
|
|
119
|
+
temperature=temperature,
|
|
120
|
+
top_p=top_p,
|
|
121
|
+
max_tokens=max_tokens,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
text = response.choices[0].message.content or ""
|
|
125
|
+
return LLMResponse(text=text, usage=self._usage(response), model=self.model, raw=response)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Factory that builds a provider from a :class:`ProviderSelection`.
|
|
2
|
+
|
|
3
|
+
Importing this module registers the built-in providers as a side effect.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ..config import ProviderSelection
|
|
9
|
+
from .base import LLMProvider, get_provider_class, registered_providers
|
|
10
|
+
|
|
11
|
+
# Importing these runs their @register_provider decorators.
|
|
12
|
+
from . import anthropic_provider as _anthropic # noqa: F401
|
|
13
|
+
from . import openai_provider as _openai # noqa: F401
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_provider(selection: ProviderSelection, *, timeout: float) -> LLMProvider:
|
|
17
|
+
"""Build the provider described by ``selection``.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
selection: Provider/model/base_url/api_key from ``.env``.
|
|
21
|
+
timeout: Request timeout in seconds, from the stage config.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A ready :class:`LLMProvider`.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ProviderError: If the provider name is not registered.
|
|
28
|
+
"""
|
|
29
|
+
cls = get_provider_class(selection.provider)
|
|
30
|
+
return cls(
|
|
31
|
+
model=selection.model,
|
|
32
|
+
api_key=selection.api_key,
|
|
33
|
+
base_url=selection.base_url,
|
|
34
|
+
timeout=timeout,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = ["build_provider", "registered_providers"]
|
text2sql/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Schema models and providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .file_provider import FileSchemaProvider
|
|
6
|
+
from .models import Column, ForeignKey, Schema, Table
|
|
7
|
+
from .provider import SchemaProvider
|
|
8
|
+
from .serialize import schema_to_prompt
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Column",
|
|
12
|
+
"ForeignKey",
|
|
13
|
+
"Schema",
|
|
14
|
+
"Table",
|
|
15
|
+
"SchemaProvider",
|
|
16
|
+
"FileSchemaProvider",
|
|
17
|
+
"schema_to_prompt",
|
|
18
|
+
]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""File-backed SchemaProvider for JSON and YAML.
|
|
2
|
+
|
|
3
|
+
The file format (documented in ``examples/schema.yaml``) is::
|
|
4
|
+
|
|
5
|
+
name: my_database # optional
|
|
6
|
+
tables:
|
|
7
|
+
- name: customers
|
|
8
|
+
description: "..."
|
|
9
|
+
columns:
|
|
10
|
+
- name: id
|
|
11
|
+
type: integer
|
|
12
|
+
description: "..."
|
|
13
|
+
primary_key: true
|
|
14
|
+
sample_values: [1, 2, 3]
|
|
15
|
+
foreign_keys:
|
|
16
|
+
- column: category_id
|
|
17
|
+
references_table: categories
|
|
18
|
+
references_column: id
|
|
19
|
+
|
|
20
|
+
JSON uses the same structure. Format is picked from the file extension.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from ..errors import SchemaError
|
|
30
|
+
from .models import Column, ForeignKey, Schema, Table
|
|
31
|
+
from .provider import SchemaProvider
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FileSchemaProvider(SchemaProvider):
|
|
35
|
+
"""Load schema metadata from a JSON or YAML file.
|
|
36
|
+
|
|
37
|
+
Format comes from the extension (``.json``, ``.yaml``, ``.yml``).
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
path: Path to the metadata file.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, path: str | Path) -> None:
|
|
44
|
+
self._path = Path(path).expanduser()
|
|
45
|
+
|
|
46
|
+
def load(self) -> Schema:
|
|
47
|
+
if not self._path.is_file():
|
|
48
|
+
raise SchemaError(f"Schema file not found: '{self._path}'.")
|
|
49
|
+
|
|
50
|
+
raw_text = self._path.read_text(encoding="utf-8")
|
|
51
|
+
suffix = self._path.suffix.lower()
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
if suffix in (".yaml", ".yml"):
|
|
55
|
+
data = self._parse_yaml(raw_text)
|
|
56
|
+
elif suffix == ".json":
|
|
57
|
+
data = json.loads(raw_text)
|
|
58
|
+
else:
|
|
59
|
+
raise SchemaError(
|
|
60
|
+
f"Unsupported schema file extension '{suffix}'. "
|
|
61
|
+
f"Use .json, .yaml, or .yml."
|
|
62
|
+
)
|
|
63
|
+
except SchemaError:
|
|
64
|
+
raise
|
|
65
|
+
except Exception as exc: # noqa: BLE001 - normalize parser errors
|
|
66
|
+
raise SchemaError(f"Failed to parse schema file '{self._path}': {exc}") from exc
|
|
67
|
+
|
|
68
|
+
return self._build_schema(data)
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _parse_yaml(text: str) -> Any:
|
|
72
|
+
try:
|
|
73
|
+
import yaml
|
|
74
|
+
except ImportError as exc: # pragma: no cover - dependency guard
|
|
75
|
+
raise SchemaError(
|
|
76
|
+
"PyYAML is required to load YAML schema files. "
|
|
77
|
+
"Install it with 'pip install PyYAML'."
|
|
78
|
+
) from exc
|
|
79
|
+
return yaml.safe_load(text)
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _build_schema(data: Any) -> Schema:
|
|
83
|
+
if not isinstance(data, dict):
|
|
84
|
+
raise SchemaError("Schema root must be a mapping/object.")
|
|
85
|
+
|
|
86
|
+
raw_tables = data.get("tables")
|
|
87
|
+
if not isinstance(raw_tables, list) or not raw_tables:
|
|
88
|
+
raise SchemaError("Schema must have a non-empty 'tables' list.")
|
|
89
|
+
|
|
90
|
+
tables: list[Table] = []
|
|
91
|
+
for entry in raw_tables:
|
|
92
|
+
if not isinstance(entry, dict):
|
|
93
|
+
raise SchemaError("Each table entry must be a mapping/object.")
|
|
94
|
+
name = entry.get("name")
|
|
95
|
+
if not name:
|
|
96
|
+
raise SchemaError("Every table needs a 'name'.")
|
|
97
|
+
|
|
98
|
+
columns: list[Column] = []
|
|
99
|
+
for col in entry.get("columns", []) or []:
|
|
100
|
+
if not isinstance(col, dict) or not col.get("name"):
|
|
101
|
+
raise SchemaError(
|
|
102
|
+
f"Invalid column in table '{name}': every column needs a 'name'."
|
|
103
|
+
)
|
|
104
|
+
columns.append(
|
|
105
|
+
Column(
|
|
106
|
+
name=str(col["name"]),
|
|
107
|
+
type=str(col.get("type", "")),
|
|
108
|
+
description=str(col.get("description", "")),
|
|
109
|
+
primary_key=bool(col.get("primary_key", False)),
|
|
110
|
+
sample_values=list(col.get("sample_values", []) or []),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
foreign_keys: list[ForeignKey] = []
|
|
115
|
+
for fk in entry.get("foreign_keys", []) or []:
|
|
116
|
+
if not isinstance(fk, dict):
|
|
117
|
+
raise SchemaError(f"Invalid foreign_key in table '{name}'.")
|
|
118
|
+
try:
|
|
119
|
+
foreign_keys.append(
|
|
120
|
+
ForeignKey(
|
|
121
|
+
column=str(fk["column"]),
|
|
122
|
+
references_table=str(fk["references_table"]),
|
|
123
|
+
references_column=str(fk["references_column"]),
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
except KeyError as exc:
|
|
127
|
+
raise SchemaError(
|
|
128
|
+
f"Foreign key in table '{name}' is missing field {exc}."
|
|
129
|
+
) from exc
|
|
130
|
+
|
|
131
|
+
tables.append(
|
|
132
|
+
Table(
|
|
133
|
+
name=str(name),
|
|
134
|
+
description=str(entry.get("description", "")),
|
|
135
|
+
columns=columns,
|
|
136
|
+
foreign_keys=foreign_keys,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return Schema(name=str(data.get("name", "")), tables=tables)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Schema metadata models.
|
|
2
|
+
|
|
3
|
+
Schema-agnostic containers. They describe any relational schema, loaded from a
|
|
4
|
+
file or from live introspection. Nothing here is tied to one database product.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Column:
|
|
15
|
+
"""A column in a table."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
type: str
|
|
19
|
+
description: str = ""
|
|
20
|
+
primary_key: bool = False
|
|
21
|
+
sample_values: list[Any] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ForeignKey:
|
|
26
|
+
"""A foreign key: a column pointing to another table's column."""
|
|
27
|
+
|
|
28
|
+
column: str
|
|
29
|
+
references_table: str
|
|
30
|
+
references_column: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Table:
|
|
35
|
+
"""A table with its columns, primary keys, and foreign keys."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
description: str = ""
|
|
39
|
+
columns: list[Column] = field(default_factory=list)
|
|
40
|
+
foreign_keys: list[ForeignKey] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def primary_keys(self) -> list[str]:
|
|
44
|
+
"""Names of the primary-key columns."""
|
|
45
|
+
return [c.name for c in self.columns if c.primary_key]
|
|
46
|
+
|
|
47
|
+
def column(self, name: str) -> Column | None:
|
|
48
|
+
"""Return the named column, or ``None``. Case-insensitive."""
|
|
49
|
+
lowered = name.lower()
|
|
50
|
+
for col in self.columns:
|
|
51
|
+
if col.name.lower() == lowered:
|
|
52
|
+
return col
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Schema:
|
|
58
|
+
"""A full schema: a list of tables."""
|
|
59
|
+
|
|
60
|
+
tables: list[Table] = field(default_factory=list)
|
|
61
|
+
name: str = ""
|
|
62
|
+
|
|
63
|
+
def table(self, name: str) -> Table | None:
|
|
64
|
+
"""Return the named table, or ``None``. Case-insensitive."""
|
|
65
|
+
lowered = name.lower()
|
|
66
|
+
for tbl in self.tables:
|
|
67
|
+
if tbl.name.lower() == lowered:
|
|
68
|
+
return tbl
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def table_names(self) -> list[str]:
|
|
73
|
+
return [t.name for t in self.tables]
|
|
74
|
+
|
|
75
|
+
def subset(self, table_names: list[str]) -> "Schema":
|
|
76
|
+
"""Return a new Schema with only the named tables.
|
|
77
|
+
|
|
78
|
+
Matches names case-insensitively. Unknown names are skipped.
|
|
79
|
+
"""
|
|
80
|
+
wanted = {n.lower() for n in table_names}
|
|
81
|
+
return Schema(
|
|
82
|
+
name=self.name,
|
|
83
|
+
tables=[t for t in self.tables if t.name.lower() in wanted],
|
|
84
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""SchemaProvider.
|
|
2
|
+
|
|
3
|
+
This is the seam that keeps the pipeline schema-agnostic. The default loads
|
|
4
|
+
from a file (see :mod:`.file_provider`). A caller can inject any subclass
|
|
5
|
+
instead, for example one that reads a live database, as long as it returns a
|
|
6
|
+
:class:`~text2sql.schema.models.Schema`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
|
|
13
|
+
from .models import Schema
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SchemaProvider(ABC):
|
|
17
|
+
"""Gives schema metadata to the pipeline.
|
|
18
|
+
|
|
19
|
+
Subclasses return a full :class:`Schema`. The pipeline does not care where
|
|
20
|
+
it comes from.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def load(self) -> Schema:
|
|
25
|
+
"""Load and return the schema.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
SchemaError: If the schema cannot be built or is invalid.
|
|
29
|
+
"""
|
|
30
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Render a :class:`Schema` to text for LLM prompts.
|
|
2
|
+
|
|
3
|
+
Kept apart from the models so prompt formatting can change without touching the
|
|
4
|
+
data classes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .models import Schema
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def schema_to_prompt(schema: Schema, *, max_sample_values: int = 5) -> str:
|
|
13
|
+
"""Render a schema as text for humans and LLMs.
|
|
14
|
+
|
|
15
|
+
Includes table descriptions, columns (type + description), primary keys,
|
|
16
|
+
foreign keys, and a capped list of sample values.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
schema: The schema to render.
|
|
20
|
+
max_sample_values: Max sample values shown per column.
|
|
21
|
+
"""
|
|
22
|
+
lines: list[str] = []
|
|
23
|
+
if schema.name:
|
|
24
|
+
lines.append(f"Database: {schema.name}")
|
|
25
|
+
lines.append("")
|
|
26
|
+
|
|
27
|
+
for table in schema.tables:
|
|
28
|
+
lines.append(f"Table: {table.name}")
|
|
29
|
+
if table.description:
|
|
30
|
+
lines.append(f" Description: {table.description}")
|
|
31
|
+
pks = table.primary_keys
|
|
32
|
+
if pks:
|
|
33
|
+
lines.append(f" Primary key: {', '.join(pks)}")
|
|
34
|
+
|
|
35
|
+
lines.append(" Columns:")
|
|
36
|
+
for col in table.columns:
|
|
37
|
+
parts = [f" - {col.name} ({col.type})"]
|
|
38
|
+
if col.description:
|
|
39
|
+
parts.append(f": {col.description}")
|
|
40
|
+
lines.append("".join(parts))
|
|
41
|
+
if col.sample_values:
|
|
42
|
+
sample = col.sample_values[:max_sample_values]
|
|
43
|
+
rendered = ", ".join(repr(v) for v in sample)
|
|
44
|
+
lines.append(f" samples: {rendered}")
|
|
45
|
+
|
|
46
|
+
if table.foreign_keys:
|
|
47
|
+
lines.append(" Foreign keys:")
|
|
48
|
+
for fk in table.foreign_keys:
|
|
49
|
+
lines.append(
|
|
50
|
+
f" - {table.name}.{fk.column} -> "
|
|
51
|
+
f"{fk.references_table}.{fk.references_column}"
|
|
52
|
+
)
|
|
53
|
+
lines.append("")
|
|
54
|
+
|
|
55
|
+
return "\n".join(lines).rstrip() + "\n"
|